@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 +2 -2
- package/package.json +1 -1
- package/src/cli.js +47 -1
- package/src/client.js +44 -4
- package/src/github.js +15 -8
- package/src/guard.js +209 -6
- package/src/index.js +748 -100
- package/src/installer.js +310 -35
- package/src/restart-protocol.js +144 -0
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`
|
|
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
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({
|
|
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
|
-
|
|
924
|
+
clearRestartPing();
|
|
898
925
|
setRestarting(false);
|
|
899
926
|
setError("2 分钟内未检测到 dsh 重启完成,请手动检查 dsh 状态后刷新页面。");
|
|
900
927
|
return;
|
|
901
928
|
}
|
|
902
|
-
|
|
903
|
-
|
|
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,
|
|
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
|
-
|
|
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 });
|