@1e0zj/dsh-plugin-mall 0.4.0 → 0.4.2

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/README.md CHANGED
@@ -16,7 +16,7 @@ Curated lists only show what has been reviewed and merged. This marketplace is o
16
16
  - **npm-first installs** — registry tarballs are smaller than whole-repo GitHub downloads and come with integrity checks. Lookups follow the registry pnpm actually installs from (profile `.npmrc` → `pnpm config get registry` → npmjs), so a mirror user keeps npm-first instead of silently falling back to whole-repo clones.
17
17
  - **Update management** — installed plugins are compared against the registry `latest`; one-click update per plugin.
18
18
  - **Conflict guard** — every install runs an isolated preflight first: the candidate is installed with scripts disabled into a throwaway directory and scanned against the live profile for loader-id collisions, double mounts, host-module shadowing and version/OS/peer ranges. A patch layer is not read entry by entry but composed the way the loader applies it — the profile is assembled with and without the candidate's layer, in the position dsh would apply it, and the two trees are diffed. That composition is checked against the loader's own `applyEntryPatches` on every test run, over fixed shapes and 300 generated patch combinations, so "what dsh would boot" is not a guess. What gets reported is what would actually change: disabling, re-enabling or replacing the config of someone else's rows warns and names them, past ten such rows the candidate is a rival composition rather than an addition and is blocked, and a bundle that is itself another front door (own `bin`, terminal peers, no browser half) is blocked once it switches rows on or off or rewrites a protection row — the fingerprint corroborates, it never blocks on its own. A hard conflict is blocked, warnings require explicit confirmation. The profile's load-bearing files are snapshotted before `pnpm` touches them and restored on failure. A pending install is resolved on the next start, however you start it: this plugin runs recovery as it loads, which only happens because dsh booted far enough to compose the profile. Starting through `guard launch` adds a grace window on top, so a plugin that boots and then crashes seconds later is rolled back and restarted once (see [Startup protection](#startup-protection-guard-cli)).
19
- - **Resilience** — rate-limit circuit breaker, GitHub's 1000-result search window handled gracefully, `corepack enable pnpm` self-heal when pnpm is missing, one-click dsh restart (loopback-only, `allowRestart: false` to disable). The successor waits for the outgoing host to be gone before it binds — starting into an occupied port would read as "the pending plugin crashed dsh" and roll back an install that was fine. It leaves no stray console behind and does not re-open the browser, because the page that asked for the restart is still there and reconnects on its own; whatever the restart prints lands in `<home>/guard/restart-<profile>.log`.
19
+ - **Resilience** — rate-limit circuit breaker, GitHub's 1000-result search window handled gracefully, `corepack enable pnpm` self-heal when pnpm is missing, one-click dsh restart (loopback-only, `allowRestart: false` to disable). The outgoing Host exits only after the on-disk helper explicitly acknowledges the current handoff protocol and stays alive through a stability window; a missing, stale or incompatible helper leaves the existing Web service running. The successor then waits for the outgoing host to be gone before it binds — starting into an occupied port would read as "the pending plugin crashed dsh" and roll back an install that was fine. It leaves no stray console behind and does not re-open the browser, because the page that asked for the restart is still there and reconnects on its own; whatever the restart prints lands in `<home>/guard/restart-<profile>.log`.
20
20
 
21
21
  ## Install
22
22
 
@@ -154,7 +154,7 @@ pnpm --dir <profile> add "github:omdsh-dev/dsh-at-file" --ignore-scripts
154
154
  - **npm 优先安装**:registry tarball 比整仓库下载更小且带完整性校验;查询用的 registry 跟随 pnpm 实际安装源(profile `.npmrc` → `pnpm config get registry` → npmjs),换了镜像也不会退化成整仓库克隆
155
155
  - **更新管理**:已装插件与 registry `latest` 比对,逐个一键更新
156
156
  - **冲突防护**:每次安装先跑隔离预检——候选包在一次性目录里以禁用脚本的方式装好后,对照 live profile 扫描 loader-id 冲突、重复挂载、宿主模块遮蔽和版本/OS/peer 范围。补丁不是逐条读,而是**按 loader 的方式组装**——把候选包的层放进 dsh 会放的位置,装前装后各组装一棵树再逐行比对。这套组装每次跑测试都会和 loader 自己的 `applyEntryPatches` 对拍(固定形状 + 300 组随机生成的补丁组合),所以「dsh 会装出什么树」不是猜的。报出来的是真正会变的东西:停用、重新启用、或替换别人整块 config,都点名是哪几条 id 并警告;超过十条就不是叠加而是另一套组合,直接拦截;候选包本身就是另一套门面(自带 bin、依赖终端栈、没有浏览器半边)时,只要它停用/启用了行、或改了沙箱审批这类保护行,就直接拦截——指纹只做佐证,单凭它不拦。硬冲突直接拦截,警告需显式确认。安装前给 profile 的承重文件拍快照、失败即回滚;pending 安装在下次启动时自动了结,**不挑启动方式**:本插件加载时就跑恢复,而能加载本身就证明 dsh 已经组装好 profile、活到了这一步。经 `guard launch` 启动则多一层观察期——插件启动几秒后才崩的情况也能回滚并原样重启一次(见下方「启动保护」)。
157
- - **工程韧性**:限流熔断、GitHub 5xx/超时退避重试(504 瞬时故障不再直达用户)、GitHub 1000 条搜索上限优雅处理、pnpm 缺失时 `corepack` 自愈、一键重启 dsh(仅 loopback,可 `allowRestart: false` 关闭)。重启会**等旧进程真正退出**再绑端口——抢在端口没释放时启动,会被判成「新装的插件把 dsh 搞崩了」,把一次本来正常的安装回滚掉。重启后不留控制台窗口、也不再重开浏览器:发起重启的那个页面还在,会自己重连;重启过程的输出写进 `<home>/guard/restart-<profile>.log`(想停 dsh 用任务管理器结束 node 进程)
157
+ - **工程韧性**:限流熔断、GitHub 5xx/超时退避重试(504 瞬时故障不再直达用户)、GitHub 1000 条搜索上限优雅处理、pnpm 缺失时 `corepack` 自愈、一键重启 dsh(仅 loopback,可 `allowRestart: false` 关闭)。旧 Host 只有在磁盘上的 helper 明确回报当前交接协议、并活过稳定窗口后才退出;helper 缺失、过旧或协议不兼容时,现有 Web 服务继续运行。之后 successor 会**等旧进程真正退出**再绑端口——抢在端口没释放时启动,会被判成「新装的插件把 dsh 搞崩了」,把一次本来正常的安装回滚掉。重启后不留控制台窗口、也不再重开浏览器:发起重启的那个页面还在,会自己重连;重启过程的输出写进 `<home>/guard/restart-<profile>.log`(想停 dsh 用任务管理器结束 node 进程)
158
158
 
159
159
  ## 安装
160
160
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1e0zj/dsh-plugin-mall",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
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
@@ -62,6 +62,7 @@ import {
62
62
  // github.js imports node builtins only — the host-independence of this CLI is
63
63
  // preserved (it must keep working when the dsh host itself is broken).
64
64
  import { npmPackageInfo } from "./github.js";
65
+ import { createRestartHelperReadyMessage } from "./restart-protocol.js";
65
66
 
66
67
  /**
67
68
  * Pin a bare package name to name@latest: pnpm's minimumReleaseAge policy
@@ -79,6 +80,24 @@ export async function pinSpecToLatest(spec, npmInfo = npmPackageInfo) {
79
80
  return spec;
80
81
  }
81
82
 
83
+ /**
84
+ * Tell the outgoing Web Host that this exact CLI understands its handoff
85
+ * protocol and has finished parsing --await-exit. A normal terminal launch has
86
+ * no IPC channel and simply skips the announcement.
87
+ */
88
+ export async function announceRestartHelperReady(awaitExitPid, send = process.send?.bind(process)) {
89
+ if (awaitExitPid === undefined || typeof send !== "function") return false;
90
+ const message = createRestartHelperReadyMessage(awaitExitPid);
91
+ await new Promise((resolvePromise, rejectPromise) => {
92
+ try {
93
+ send(message, (error) => error ? rejectPromise(error) : resolvePromise());
94
+ } catch (error) {
95
+ rejectPromise(error);
96
+ }
97
+ });
98
+ return true;
99
+ }
100
+
82
101
  // ── small helpers ────────────────────────────────────────────────────────────
83
102
 
84
103
  // Same shell-metacharacter blocklist as installer.assertSafeSpec. The install
@@ -884,7 +903,15 @@ function markerLooksValid(marker, profileDir, home) {
884
903
  * A corrupt or legacy (pre-v2) marker, or a failed static-recovery step, fails
885
904
  * closed: the command is NOT launched and no unvalidated path is deleted.
886
905
  */
887
- async function cmdLaunch({ profile, home, graceMs, commandArgv, awaitExitPid, _waitForExit = waitForProcessExit }) {
906
+ async function cmdLaunch({
907
+ profile,
908
+ home,
909
+ graceMs,
910
+ commandArgv,
911
+ awaitExitPid,
912
+ _waitForExit = waitForProcessExit,
913
+ _onAwaitExit = announceRestartHelperReady,
914
+ }) {
888
915
  const profileDir = profileDirOf(home, profile);
889
916
  const grace = graceMs ?? DEFAULT_GRACE_MS;
890
917
  const [command, ...args] = commandArgv;
@@ -899,6 +926,10 @@ async function cmdLaunch({ profile, home, graceMs, commandArgv, awaitExitPid, _w
899
926
  // the outcome that costs the user their install; not starting leaves the old
900
927
  // host running and the marker pending, which the next launch resolves.
901
928
  if (awaitExitPid !== undefined) {
929
+ // This is the exact handoff boundary: arguments and profile are valid, and
930
+ // the helper is about to block on the outgoing Host. The Web parent waits
931
+ // for this IPC acknowledgement before it allows itself to exit.
932
+ await _onAwaitExit(awaitExitPid);
902
933
  if (!await _waitForExit(awaitExitPid, AWAIT_EXIT_TIMEOUT_MS)) {
903
934
  throw new Error(`process ${awaitExitPid} was still running after ${AWAIT_EXIT_TIMEOUT_MS}ms — refusing to start a second host that would collide with it on the listening port (the old one is still up; nothing was changed)`);
904
935
  }
@@ -1184,6 +1215,18 @@ async function selfTest() {
1184
1215
  // A pid after `--` belongs to the wrapped command, not to us.
1185
1216
  const p6 = parseArgs(["launch", "--profile", "web", "--", "dsh", "--await-exit", "7"]);
1186
1217
  if (p6.awaitExit !== undefined || p6.commandArgv.join(" ") !== "dsh --await-exit 7") throw new Error("--await-exit after `--` must stay with the wrapped command");
1218
+
1219
+ let announced;
1220
+ const didAnnounce = await announceRestartHelperReady(4321, (message, callback) => {
1221
+ announced = message;
1222
+ callback();
1223
+ });
1224
+ if (!didAnnounce || announced?.awaitExitPid !== 4321 || announced?.protocol !== 1) {
1225
+ throw new Error("restart helper must announce the parsed pid and protocol over IPC");
1226
+ }
1227
+ if (await announceRestartHelperReady(undefined, () => { throw new Error("must not send"); }) !== false) {
1228
+ throw new Error("a normal launch without --await-exit must not announce a restart handoff");
1229
+ }
1187
1230
  }
1188
1231
 
1189
1232
  // `--await-exit`: the successor must not start while the outgoing host is
@@ -1199,6 +1242,7 @@ async function selfTest() {
1199
1242
 
1200
1243
  let launched = false;
1201
1244
  const neverExits = async () => false;
1245
+ let announcedPid;
1202
1246
  let refused = false;
1203
1247
  try {
1204
1248
  await cmdLaunch({
@@ -1207,11 +1251,13 @@ async function selfTest() {
1207
1251
  commandArgv: [process.execPath, "-e", "launched=1"],
1208
1252
  awaitExitPid: 999999,
1209
1253
  _waitForExit: neverExits,
1254
+ _onAwaitExit: async (pid) => { announcedPid = pid; },
1210
1255
  });
1211
1256
  } catch (error) {
1212
1257
  refused = /still running after/.test(error.message);
1213
1258
  }
1214
1259
  if (!refused) throw new Error("a pid that outlives the timeout must refuse the launch, not race it");
1260
+ if (announcedPid !== 999999) throw new Error("launch must announce readiness immediately before waiting for the outgoing pid");
1215
1261
 
1216
1262
  // …and a pid that does go away lets the command through.
1217
1263
  const exitsPromptly = async () => true;
package/src/client.js CHANGED
@@ -661,6 +661,25 @@ window.__ModuleLoader__.load({
661
661
  // No boolean warning-consent map is kept.
662
662
  var sessionNonce = useRef(generateSessionNonce()).current;
663
663
  var approvalTokensRef = useRef({});
664
+ var restartControlRef = useRef({ mounted: true, ping: null });
665
+ var clearRestartPing = useCallback(function () {
666
+ var control = restartControlRef.current;
667
+ if (control.ping !== null) {
668
+ clearInterval(control.ping);
669
+ control.ping = null;
670
+ }
671
+ }, []);
672
+ useEffect(function () {
673
+ var control = restartControlRef.current;
674
+ control.mounted = true;
675
+ return function () {
676
+ control.mounted = false;
677
+ if (control.ping !== null) {
678
+ clearInterval(control.ping);
679
+ control.ping = null;
680
+ }
681
+ };
682
+ }, []);
664
683
 
665
684
  var call = useCallback(function (endpoint, payload) {
666
685
  var body = Object.assign({ session: sessionNonce }, payload || {});
@@ -887,28 +906,49 @@ window.__ModuleLoader__.load({
887
906
  if (typeof window !== "undefined" && typeof window.confirm === "function") {
888
907
  if (window.confirm("重启 dsh?正在进行的任务会中断。") !== true) return;
889
908
  }
909
+ clearRestartPing();
890
910
  setRestarting(true);
891
911
  setError(null);
912
+ var requestedAt = Date.now();
913
+ var previousHostStartedAt = Number(hostStartedAt);
892
914
  call("restart", {}).then(function () {
915
+ if (restartControlRef.current.mounted !== true) return;
893
916
  var tries = 0;
894
917
  var ping = setInterval(function () {
918
+ if (restartControlRef.current.mounted !== true) {
919
+ clearRestartPing();
920
+ return;
921
+ }
895
922
  tries++;
896
923
  if (tries > 40) {
897
- clearInterval(ping);
924
+ clearRestartPing();
898
925
  setRestarting(false);
899
926
  setError("2 分钟内未检测到 dsh 重启完成,请手动检查 dsh 状态后刷新页面。");
900
927
  return;
901
928
  }
902
- rpc.call("/market", "installed", {}).then(function () {
903
- clearInterval(ping);
929
+ // Prefer identity change over wall-clock ordering: NTP may move the
930
+ // successor's timeOrigin backwards. If initial restore did not
931
+ // provide an identity, retain the stricter requestedAt fallback.
932
+ call("jobs", {}).then(function (value) {
933
+ if (restartControlRef.current.mounted !== true) return;
934
+ var observedHostStartedAt = Number(value.hostStartedAt);
935
+ if (!Number.isFinite(observedHostStartedAt) || observedHostStartedAt <= 0) return;
936
+ var hadPreviousIdentity = Number.isFinite(previousHostStartedAt) && previousHostStartedAt > 0;
937
+ if (hadPreviousIdentity
938
+ ? observedHostStartedAt === previousHostStartedAt
939
+ : observedHostStartedAt <= requestedAt) return;
940
+ clearRestartPing();
904
941
  window.location.reload();
905
942
  }).catch(function () { /* host restarting */ });
906
943
  }, 3000);
944
+ restartControlRef.current.ping = ping;
907
945
  }).catch(function (e) {
946
+ if (restartControlRef.current.mounted !== true) return;
947
+ clearRestartPing();
908
948
  setRestarting(false);
909
949
  setError(errorText(e));
910
950
  });
911
- }, [call, rpc]);
951
+ }, [call, clearRestartPing, hostStartedAt]);
912
952
 
913
953
  var doUninstall = useCallback(function (name) {
914
954
  delete approvalTokensRef.current[name];
package/src/github.js CHANGED
@@ -172,9 +172,11 @@ 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).
175
177
  * @returns `{latest, repositoryUrl, hostDeps}`, or null when unknown/unreachable.
176
178
  */
177
- export async function npmPackageInfo(name, { registry } = {}) {
179
+ export async function npmPackageInfo(name, { registry, signal } = {}) {
178
180
  const clean = String(name ?? "").trim();
179
181
  if (clean.length === 0 || !/^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/i.test(clean)) return null;
180
182
  const base = normalizeRegistry(registry);
@@ -188,6 +190,7 @@ export async function npmPackageInfo(name, { registry } = {}) {
188
190
  // 镜像(npmmirror 等)的同名端点响应格式一致,两个字段都保留。
189
191
  const response = await fetch(`${base}/${clean.replace("/", "%2F")}/latest`, {
190
192
  headers: { "User-Agent": "dsh-plugin-mall", Accept: "application/json" },
193
+ signal,
191
194
  });
192
195
  if (response.ok) {
193
196
  const body = await response.json();
@@ -201,7 +204,11 @@ export async function npmPackageInfo(name, { registry } = {}) {
201
204
  };
202
205
  }
203
206
  }
204
- } catch {
207
+ } catch (error) {
208
+ // 取消不是「查不到」。若把 AbortError 也吞成 null,下面的 npmCache.set 会把
209
+ // 这个空答案钉住整个 TTL——用户取消一次,接下来 5 分钟里防抢注永远不匹配、
210
+ // 宿主影子检查看不到清单,而且完全无声。取消一律上抛,不进缓存。
211
+ if (error?.name === "AbortError") throw error;
205
212
  info = null; // registry unreachable — caller falls back
206
213
  }
207
214
  npmCache.set(key, { info, at: Date.now() });
@@ -213,7 +220,7 @@ export async function npmPackageInfo(name, { registry } = {}) {
213
220
  * that package exists on npm AND its repository URL points back at the repo
214
221
  * (anti-squatting). Anything else passes through untouched.
215
222
  */
216
- export async function preferNpmSpec({ spec, registry, sources }) {
223
+ export async function preferNpmSpec({ spec, registry, sources, signal }) {
217
224
  const raw = String(spec ?? "");
218
225
  // A scoped npm name ("@scope/name", "@scope/name@1.2.3") is shaped exactly
219
226
  // like owner/repo and matches the regex below, sending every such install
@@ -223,10 +230,10 @@ export async function preferNpmSpec({ spec, registry, sources }) {
223
230
  const githubMatch = /^(?:github:)?([^/\s]+\/[^/\s]+?)(?:\.git)?$/i.exec(raw);
224
231
  if (githubMatch === null) return raw;
225
232
  const repo = githubMatch[1];
226
- const { results } = await verifyPlugins({ repos: [repo], sources }); // cache hit after first verify
233
+ const { results } = await verifyPlugins({ repos: [repo], sources, signal }); // cache hit after first verify
227
234
  const declaredName = results[repo]?.name;
228
235
  if (typeof declaredName !== "string") return raw;
229
- const info = await npmPackageInfo(declaredName, { registry });
236
+ const info = await npmPackageInfo(declaredName, { registry, signal });
230
237
  if (info === null || info.repositoryUrl === undefined) return raw;
231
238
  const pointsBack = new RegExp(`github\\.com[/:]${repo.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(/|\\.git|$)`, "i").test(info.repositoryUrl);
232
239
  return pointsBack ? declaredName : raw;
@@ -254,7 +261,7 @@ export function npmNameOf(raw) {
254
261
  return match === null ? null : match[1];
255
262
  }
256
263
 
257
- export async function assertSafeToInstall({ spec, registry, sources }) {
264
+ export async function assertSafeToInstall({ spec, registry, sources, signal }) {
258
265
  const raw = String(spec ?? "");
259
266
  let hostDeps;
260
267
  if (/^(?:file:|link:)/i.test(raw)) {
@@ -268,7 +275,7 @@ export async function assertSafeToInstall({ spec, registry, sources }) {
268
275
  }
269
276
  } else if (/^github:([^/\s]+\/[^/\s]+?)(?:\.git)?(?:#.+)?$/i.test(raw)) {
270
277
  const repo = /^github:([^/\s]+\/[^/\s]+?)(?:\.git)?(?:#.+)?$/i.exec(raw)[1];
271
- const { results } = await verifyPlugins({ repos: [repo], sources });
278
+ const { results } = await verifyPlugins({ repos: [repo], sources, signal });
272
279
  hostDeps = results[repo]?.hostDeps;
273
280
  } else if (/^https?:\/\//i.test(raw)) {
274
281
  return; // 远程 tarball 下载前无法廉价检查;罕见路径,放行
@@ -279,7 +286,7 @@ export async function assertSafeToInstall({ spec, registry, sources }) {
279
286
  // 无法识别形状的 spec 一律拒绝,而不是静默跳过检查。
280
287
  throw new Error(`cannot analyze install spec ${JSON.stringify(raw)} for host-shadow dependencies — refusing to install`);
281
288
  }
282
- const info = await npmPackageInfo(name, { registry });
289
+ const info = await npmPackageInfo(name, { registry, signal });
283
290
  hostDeps = info?.hostDeps;
284
291
  }
285
292
  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 });