@1e0zj/dsh-plugin-mall 0.4.1 → 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/package.json +1 -1
- package/src/github.js +15 -8
- package/src/guard.js +209 -6
- package/src/index.js +516 -83
- package/src/installer.js +310 -35
package/package.json
CHANGED
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
|
-
|
|
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) =>
|
|
1486
|
-
child.on("close", (exitCode) =>
|
|
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(
|
|
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
|
-
|
|
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 });
|