@1e0zj/dsh-plugin-mall 0.3.0 → 0.3.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/client.js +120 -26
- package/src/guard.js +246 -16
- package/src/index.js +113 -3
- package/src/installer.js +143 -49
package/package.json
CHANGED
package/src/client.js
CHANGED
|
@@ -133,10 +133,29 @@ window.__ModuleLoader__.load({
|
|
|
133
133
|
// onPreflightSettled:预检 job(kind=dsh-plugin-preflight)落定时回调,
|
|
134
134
|
// 携带 (spec, report)。safe 由调用方直接续装;有风险由调用方出内联卡片。
|
|
135
135
|
function useJobPolling(call, onSettled, onApprovalToken, onPreflightSettled) {
|
|
136
|
-
|
|
137
|
-
|
|
136
|
+
// 重挂载垫底:安装完成的收尾会写 cordis.patch.yml,dsh 随之重放装配树、
|
|
137
|
+
// 整个市场 UI 重挂载,React state 归零——面板先空一拍再被异步的
|
|
138
|
+
// call("jobs") 恢复,用户看到「任务清掉又回来」的割裂(实测反馈)。
|
|
139
|
+
// sessionStorage 镜像让重挂载的第一帧直接渲染上一帧的面板,随后的
|
|
140
|
+
// RPC 恢复用后端权威数据覆盖。tab 级存储,随 tab 关闭而清,
|
|
141
|
+
// 与内存里的任务数据同生命周期。
|
|
142
|
+
var JOBS_MIRROR_KEY = "@1e0zj/dsh-plugin-mall:jobs";
|
|
143
|
+
var readJobsMirror = function () {
|
|
144
|
+
try {
|
|
145
|
+
var parsed = JSON.parse(window.sessionStorage.getItem(JOBS_MIRROR_KEY) || "null");
|
|
146
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
147
|
+
} catch (e) { return {}; }
|
|
148
|
+
};
|
|
149
|
+
var jobsRef = useRef(null);
|
|
150
|
+
if (jobsRef.current === null) jobsRef.current = readJobsMirror();
|
|
151
|
+
var _jobs = useState(Object.assign({}, jobsRef.current));
|
|
138
152
|
var jobs = _jobs[0];
|
|
139
153
|
var setJobs = _jobs[1];
|
|
154
|
+
var commit = useCallback(function (next) {
|
|
155
|
+
jobsRef.current = next;
|
|
156
|
+
setJobs(Object.assign({}, next));
|
|
157
|
+
try { window.sessionStorage.setItem(JOBS_MIRROR_KEY, JSON.stringify(next)); } catch (e) { /* 存储被禁/写满不致命 */ }
|
|
158
|
+
}, []);
|
|
140
159
|
useEffect(function () {
|
|
141
160
|
var timer = setInterval(function () {
|
|
142
161
|
var current = jobsRef.current;
|
|
@@ -165,15 +184,15 @@ window.__ModuleLoader__.load({
|
|
|
165
184
|
}
|
|
166
185
|
}
|
|
167
186
|
var output = (old.output || "") + (value.output || "");
|
|
168
|
-
|
|
187
|
+
commit(Object.assign({}, jobsRef.current, { [id]: Object.assign({}, old, {
|
|
169
188
|
status: snapshot.status,
|
|
170
189
|
detail: snapshot.detail,
|
|
171
190
|
needsApproval: snapshot.needsApproval,
|
|
172
191
|
approvalToken: snapshot.approvalToken,
|
|
173
192
|
kind: snapshot.kind,
|
|
174
193
|
output: output,
|
|
175
|
-
|
|
176
|
-
|
|
194
|
+
finishedAt: snapshot.finishedAt,
|
|
195
|
+
}) }));
|
|
177
196
|
}).catch(function () { /* keep polling */ });
|
|
178
197
|
});
|
|
179
198
|
}, 1200);
|
|
@@ -182,6 +201,9 @@ window.__ModuleLoader__.load({
|
|
|
182
201
|
// carryFromId:把上一阶段(预检)的日志接过来并撤掉它的条目。一次点击
|
|
183
202
|
// 只应该在面板里留下一个任务,日志连续——而不是 market-1 预检、
|
|
184
203
|
// market-2 安装两条并排,让人以为自己点了两次。
|
|
204
|
+
// 重试同理:同一 spec 上一轮失败/完成的终态条目一并撤掉——用户没点
|
|
205
|
+
// 「清空」就重试时,面板照样只留新一轮一条。旧失败日志不拼进新任务
|
|
206
|
+
// (两轮 pnpm 输出混在一起没法读);running 的不动,那是真并发任务。
|
|
185
207
|
var track = useCallback(function (id, spec, carryFromId) {
|
|
186
208
|
var next = Object.assign({}, jobsRef.current);
|
|
187
209
|
var carried = "";
|
|
@@ -189,21 +211,72 @@ window.__ModuleLoader__.load({
|
|
|
189
211
|
carried = next[carryFromId].output || "";
|
|
190
212
|
delete next[carryFromId];
|
|
191
213
|
}
|
|
214
|
+
for (var key in next) {
|
|
215
|
+
if (key !== id && next[key] && next[key].spec === spec && next[key].status !== "running") {
|
|
216
|
+
delete next[key];
|
|
217
|
+
}
|
|
218
|
+
}
|
|
192
219
|
next[id] = { status: "running", spec: spec, output: carried };
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
}, []);
|
|
220
|
+
commit(next);
|
|
221
|
+
}, [commit]);
|
|
196
222
|
var clear = useCallback(function () {
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
}, []);
|
|
223
|
+
commit({});
|
|
224
|
+
}, [commit]);
|
|
200
225
|
var drop = useCallback(function (id) {
|
|
201
226
|
var next = Object.assign({}, jobsRef.current);
|
|
202
227
|
delete next[id];
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
228
|
+
commit(next);
|
|
229
|
+
}, [commit]);
|
|
230
|
+
// 恢复后端任务记录(tracker.list 的形状):安装事务改写
|
|
231
|
+
// cordis.patch.yml 会让 dsh 重放装配树、整个市场 UI 重挂载,React
|
|
232
|
+
// state 全丢——任务面板、完成提醒、重启按钮一起消失(真实事故:
|
|
233
|
+
// 更新其实成功了,用户靠手动重启+查版本才确认)。记录在后端活着,
|
|
234
|
+
// 挂载时拉回来。两个细节:恢复出的**已落定**预检任务标
|
|
235
|
+
// preflightHandled,否则轮询的第一拍会重放 onPreflightSettled——
|
|
236
|
+
// 那等于页面一刷新就自动续装一次;running 的不标,交回轮询线。
|
|
237
|
+
var restore = useCallback(function (entries) {
|
|
238
|
+
var next = Object.assign({}, jobsRef.current);
|
|
239
|
+
var serverIds = {};
|
|
240
|
+
for (var index = 0; index < (entries || []).length; index++) {
|
|
241
|
+
var entry = entries[index] || {};
|
|
242
|
+
var snap = entry.snapshot || {};
|
|
243
|
+
if (!entry.id) continue;
|
|
244
|
+
serverIds[entry.id] = true;
|
|
245
|
+
// 服务器记录是权威(覆盖垫底镜像);本地独有的 id 保留——后端
|
|
246
|
+
// 修剪掉的旧条目不至于从面板上闪没。
|
|
247
|
+
next[entry.id] = {
|
|
248
|
+
status: snap.status,
|
|
249
|
+
spec: snap.spec,
|
|
250
|
+
detail: snap.detail,
|
|
251
|
+
needsApproval: snap.needsApproval,
|
|
252
|
+
approvalToken: snap.approvalToken,
|
|
253
|
+
kind: snap.kind,
|
|
254
|
+
output: entry.output || "",
|
|
255
|
+
finishedAt: snap.finishedAt,
|
|
256
|
+
preflightHandled: snap.kind === "dsh-plugin-preflight" && snap.status !== "running",
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
// 不在本次服务器列表里的条目属于上一次宿主会话(进程重启后
|
|
260
|
+
// tracker 清空)。已兑现的直接翻篇撤掉:completed 的重启已经
|
|
261
|
+
// 发生;needsApproval 暂停的批准卡片已随进程失效(事务由启动
|
|
262
|
+
// 恢复处置),留着只会让人点一个必然失败的按钮。running 的标
|
|
263
|
+
// 中断,别让轮询对着不存在的 id 空转。failed 保留——日志还有
|
|
264
|
+
// 排障价值。这个判据不依赖 finishedAt(旧镜像里没有该字段)。
|
|
265
|
+
for (var key in next) {
|
|
266
|
+
if (serverIds[key]) continue;
|
|
267
|
+
var stale = next[key];
|
|
268
|
+
if (stale.status === "running") {
|
|
269
|
+
next[key] = Object.assign({}, stale, {
|
|
270
|
+
status: "killed",
|
|
271
|
+
detail: "宿主进程已重启,该任务的记录随之丢失",
|
|
272
|
+
});
|
|
273
|
+
} else if (stale.status === "completed" || (Array.isArray(stale.needsApproval) && stale.needsApproval.length > 0)) {
|
|
274
|
+
delete next[key];
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
commit(next);
|
|
278
|
+
}, [commit]);
|
|
279
|
+
return { jobs: jobs, track: track, clear: clear, drop: drop, restore: restore };
|
|
207
280
|
}
|
|
208
281
|
|
|
209
282
|
// ── plugin verification badge ───────────────────────────────────────────
|
|
@@ -417,10 +490,12 @@ window.__ModuleLoader__.load({
|
|
|
417
490
|
needsApproval: job.needsApproval,
|
|
418
491
|
busy: props.approving === job.spec,
|
|
419
492
|
onApprove: function (names) {
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
493
|
+
// 不先 drop:旧条目由重试任务的 track(carryFromId) 原子接管
|
|
494
|
+
// (撤条目 + 日志接续一拍完成)。先删的话,call("install")
|
|
495
|
+
// 要走数秒(重试还会重跑一次隔离预检),面板会空白一段,
|
|
496
|
+
// 「批准后任务消失、开始安装才冒出来」的割裂就是这么来的。
|
|
497
|
+
// 等待期间按钮由 approving 态显示「继续中…」。
|
|
498
|
+
props.onApprove(job.spec, names, job.approvalToken, id);
|
|
424
499
|
},
|
|
425
500
|
onDismiss: function () { props.onDismiss(id); },
|
|
426
501
|
})
|
|
@@ -429,11 +504,16 @@ window.__ModuleLoader__.load({
|
|
|
429
504
|
// 也不再重复一个绿色「完成」:状态行已经写了「· 完成」。
|
|
430
505
|
done && job.status === "completed" && job.kind !== "dsh-plugin-preflight"
|
|
431
506
|
? h("div", { className: "mkt_jobDone" },
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
507
|
+
// 完成时间早于本次宿主启动 = 重启已经发生过了(防御分支:
|
|
508
|
+
// 已兑现的条目通常在恢复时就被撤掉了):换成说明文字,
|
|
509
|
+
// 不再催一次没必要的重启。
|
|
510
|
+
props.hostStartedAt && job.finishedAt && job.finishedAt < props.hostStartedAt
|
|
511
|
+
? h("span", { className: "mkt_meta" }, "重启已生效")
|
|
512
|
+
: h("button", {
|
|
513
|
+
className: "mkt_btn mkt_btnPrimary mkt_btnSm",
|
|
514
|
+
disabled: props.restarting === true,
|
|
515
|
+
onClick: props.onRestart,
|
|
516
|
+
}, props.restarting ? "重启中…" : "重启 dsh 生效"))
|
|
437
517
|
: job.status === "failed" && !(job.needsApproval && job.needsApproval.length > 0)
|
|
438
518
|
? h("div", { className: "mkt_error" }, "失败,见下方输出")
|
|
439
519
|
: null,
|
|
@@ -559,6 +639,11 @@ window.__ModuleLoader__.load({
|
|
|
559
639
|
var _restarting = useState(false);
|
|
560
640
|
var restarting = _restarting[0];
|
|
561
641
|
var setRestarting = _restarting[1];
|
|
642
|
+
// 本次宿主进程的启动时间(jobs 端点带回):完成时间早于它的任务,
|
|
643
|
+
// 其「重启 dsh 生效」按钮已经兑现,改显示「重启已生效」。
|
|
644
|
+
var _hostStartedAt = useState(0);
|
|
645
|
+
var hostStartedAt = _hostStartedAt[0];
|
|
646
|
+
var setHostStartedAt = _hostStartedAt[1];
|
|
562
647
|
var _preflight = useState(null);
|
|
563
648
|
var preflight = _preflight[0];
|
|
564
649
|
var setPreflight = _preflight[1];
|
|
@@ -716,6 +801,14 @@ window.__ModuleLoader__.load({
|
|
|
716
801
|
|
|
717
802
|
useEffect(function () {
|
|
718
803
|
doSearch();
|
|
804
|
+
// 挂载即恢复任务面板(见 useJobPolling.restore 的注释):重挂载丢掉
|
|
805
|
+
// 的完成提醒、重启按钮、暂停中的批准卡片都从后端拉回来。
|
|
806
|
+
// hostStartedAt:本次宿主进程的启动时间——早于它的完成任务说明
|
|
807
|
+
// 重启已经发生,按钮要换成「重启已生效」而不是再催一次。
|
|
808
|
+
call("jobs", {}).then(function (value) {
|
|
809
|
+
if (value.hostStartedAt) setHostStartedAt(value.hostStartedAt);
|
|
810
|
+
polling.restore(value.jobs);
|
|
811
|
+
}).catch(function () { /* 恢复失败不阻塞面板 */ });
|
|
719
812
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
720
813
|
}, []);
|
|
721
814
|
|
|
@@ -740,14 +833,14 @@ window.__ModuleLoader__.load({
|
|
|
740
833
|
});
|
|
741
834
|
}, [call, track]);
|
|
742
835
|
|
|
743
|
-
var doApprove = useCallback(function (spec, names, token) {
|
|
836
|
+
var doApprove = useCallback(function (spec, names, token, carryFromId) {
|
|
744
837
|
var extra = { allowBuildScripts: names };
|
|
745
838
|
var apprToken = token || approvalTokensRef.current[spec];
|
|
746
839
|
if (apprToken) {
|
|
747
840
|
extra.approvalToken = apprToken;
|
|
748
841
|
delete approvalTokensRef.current[spec];
|
|
749
842
|
}
|
|
750
|
-
doRawInstall(spec, extra);
|
|
843
|
+
doRawInstall(spec, extra, carryFromId);
|
|
751
844
|
}, [doRawInstall]);
|
|
752
845
|
|
|
753
846
|
// 预检落定后的去向:safe 直接续装,其余出内联风险卡片。
|
|
@@ -904,6 +997,7 @@ window.__ModuleLoader__.load({
|
|
|
904
997
|
onDrop: dropJob,
|
|
905
998
|
onRestart: doRestart,
|
|
906
999
|
restarting: restarting,
|
|
1000
|
+
hostStartedAt: hostStartedAt,
|
|
907
1001
|
approving: Object.keys(installing).filter(function (s) { return installing[s]; })[0],
|
|
908
1002
|
}),
|
|
909
1003
|
preflight ? h(PreflightCard, {
|
package/src/guard.js
CHANGED
|
@@ -583,9 +583,9 @@ export function inspectRemoteCandidate({ profileDir, manifest, patchText, spec }
|
|
|
583
583
|
}
|
|
584
584
|
|
|
585
585
|
/** Locate a binary on PATH by explicit extension (no shell, no PATHEXT guessing). */
|
|
586
|
-
function findOnPath(binary, extensions) {
|
|
587
|
-
const separator =
|
|
588
|
-
for (const dir of String(
|
|
586
|
+
function findOnPath(binary, { platform, pathEnv, extensions }) {
|
|
587
|
+
const separator = platform === "win32" ? ";" : ":";
|
|
588
|
+
for (const dir of String(pathEnv ?? "").split(separator)) {
|
|
589
589
|
if (dir.length === 0) continue;
|
|
590
590
|
for (const ext of extensions) {
|
|
591
591
|
const candidate = join(dir, `${binary}${ext}`);
|
|
@@ -596,16 +596,32 @@ function findOnPath(binary, extensions) {
|
|
|
596
596
|
}
|
|
597
597
|
|
|
598
598
|
/**
|
|
599
|
-
*
|
|
600
|
-
*
|
|
601
|
-
*
|
|
599
|
+
* How to spawn pnpm on this platform. installer.js consumes this too, so the
|
|
600
|
+
* plan lives here exactly once instead of as two drifting copies (the mirror
|
|
601
|
+
* copies had already diverged once; that is how the quoting bug below stayed
|
|
602
|
+
* invisible on both sides).
|
|
603
|
+
* @returns {{ command: string, shell: boolean, treeKill: boolean }}
|
|
604
|
+
* treeKill marks the shell-wrapped case: cancel must taskkill /T the tree.
|
|
602
605
|
*/
|
|
603
|
-
function pnpmSpawnPlan() {
|
|
604
|
-
if (
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
606
|
+
export function pnpmSpawnPlan({ platform = process.platform, pathEnv = process.env.PATH } = {}) {
|
|
607
|
+
if (platform !== "win32") return { command: "pnpm", shell: false, treeKill: false };
|
|
608
|
+
// A real .exe spawns without a shell — cancel then kills pnpm itself.
|
|
609
|
+
const exe = findOnPath("pnpm", { platform, pathEnv, extensions: [".exe"] });
|
|
610
|
+
if (exe !== undefined) return { command: exe, shell: false, treeKill: false };
|
|
611
|
+
// Only the .cmd shim: Node refuses batch files with shell:false (EINVAL
|
|
612
|
+
// since the batch-file argument-injection fix), so a cmd wrapper is
|
|
613
|
+
// unavoidable — flag it so cancel kills the whole tree, not the wrapper.
|
|
614
|
+
const cmd = findOnPath("pnpm", { platform, pathEnv, extensions: [".cmd"] });
|
|
615
|
+
if (cmd === undefined) return { command: "pnpm", shell: true, treeKill: true };
|
|
616
|
+
// The shim usually sits in a directory with a space (`D:\Program Files\nodejs`
|
|
617
|
+
// is Node's default install layout). Under shell:true Node joins command and
|
|
618
|
+
// args with spaces WITHOUT quoting per argument — the same fact the
|
|
619
|
+
// UNSAFE_SPEC_RE comment below argues from — so an unquoted path is cut at
|
|
620
|
+
// the first space and cmd answers `'D:\Program' is not recognized`, killing
|
|
621
|
+
// every preflight and install on such machines. Quote the command ourselves;
|
|
622
|
+
// the args joined after it are fixed flags plus an assertSafeSpec-validated
|
|
623
|
+
// spec, none of which carry spaces.
|
|
624
|
+
return { command: `"${cmd}"`, shell: true, treeKill: true };
|
|
609
625
|
}
|
|
610
626
|
|
|
611
627
|
function spawnCapture(command, args, options, onOutput) {
|
|
@@ -641,7 +657,9 @@ function spawnCapture(command, args, options, onOutput) {
|
|
|
641
657
|
// not only by the agent/browser callers that happen to check first: anything
|
|
642
658
|
// carrying shell metacharacters, or a Windows `file:`/`link:` path with spaces,
|
|
643
659
|
// is refused before a single byte of filesystem work and before pnpm is spawned.
|
|
644
|
-
|
|
660
|
+
// `%` is on the list like everywhere else: cmd performs `%VAR%` expansion, and
|
|
661
|
+
// the expanded value (often full of spaces and semicolons) reshapes the argv.
|
|
662
|
+
const UNSAFE_SPEC_RE = /[;&|`$()<>^%!"*\n\r]/;
|
|
645
663
|
|
|
646
664
|
function assertSafeSpec(spec) {
|
|
647
665
|
const value = String(spec ?? "");
|
|
@@ -862,7 +880,7 @@ function sanitizeSnapshot(marker, home) {
|
|
|
862
880
|
}
|
|
863
881
|
|
|
864
882
|
/** Read and validate a profile's pending marker; undefined when none exists. */
|
|
865
|
-
function readValidatedPendingSnapshot(profileDir) {
|
|
883
|
+
export function readValidatedPendingSnapshot(profileDir) {
|
|
866
884
|
const resolved = resolve(profileDir);
|
|
867
885
|
const filePath = pendingPath(resolved);
|
|
868
886
|
if (!existsSync(filePath)) return undefined;
|
|
@@ -1024,6 +1042,74 @@ function reconcileInstallArgs() {
|
|
|
1024
1042
|
];
|
|
1025
1043
|
}
|
|
1026
1044
|
|
|
1045
|
+
/**
|
|
1046
|
+
* Args for the per-package fallback reinstall. pnpm 11's headless "up to date"
|
|
1047
|
+
* short-circuit (node_modules/.pnpm/lock.yaml) can skip a `--frozen` install
|
|
1048
|
+
* even while the package is actually missing — `--force` does not bypass it
|
|
1049
|
+
* (verified on a real profile, twice, each time leaving the profile without
|
|
1050
|
+
* the plugin and dsh unable to boot). `pnpm add <spec>` always goes through
|
|
1051
|
+
* full resolution, so it is the reliable way to relink one missing direct
|
|
1052
|
+
* dependency. Still strictly offline.
|
|
1053
|
+
*/
|
|
1054
|
+
function fallbackAddArgs(target) {
|
|
1055
|
+
return [
|
|
1056
|
+
"add", target,
|
|
1057
|
+
"--ignore-scripts",
|
|
1058
|
+
"--config.auto-install-peers=false",
|
|
1059
|
+
"--reporter=append-only",
|
|
1060
|
+
"--offline",
|
|
1061
|
+
];
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
/**
|
|
1065
|
+
* The argv target for a fallback `pnpm add` of one restored dependency, or
|
|
1066
|
+
* undefined when that spec cannot be added offline and safely. Semver ranges
|
|
1067
|
+
* become `name@range`, local file:/link: paths add by the spec itself;
|
|
1068
|
+
* github:/git+ specs need the network or git and stay fail-closed, and
|
|
1069
|
+
* anything carrying shell metacharacters (a multi-clause range like
|
|
1070
|
+
* `^1.0.0 || ^2.0.0` contains spaces and pipes) is skipped — the
|
|
1071
|
+
* shell-wrapped pnpm spawn joins argv with spaces without quoting.
|
|
1072
|
+
*/
|
|
1073
|
+
function fallbackAddTarget(name, spec) {
|
|
1074
|
+
const range = String(spec ?? "");
|
|
1075
|
+
if (range.length === 0) return undefined;
|
|
1076
|
+
const isLocal = /^(?:file:|link:)/i.test(range);
|
|
1077
|
+
if (!isLocal && validRange(range) === null) return undefined;
|
|
1078
|
+
const target = isLocal ? range : `${name}@${range}`;
|
|
1079
|
+
try {
|
|
1080
|
+
assertSafeSpec(target);
|
|
1081
|
+
} catch {
|
|
1082
|
+
return undefined;
|
|
1083
|
+
}
|
|
1084
|
+
return target;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
/**
|
|
1088
|
+
* Run one fallback `pnpm add` synchronously. Same contract as
|
|
1089
|
+
* runReconcileInstall: never throws, failures surface as a nonzero exit.
|
|
1090
|
+
*/
|
|
1091
|
+
function runFallbackAdd(profileDir, target) {
|
|
1092
|
+
let result;
|
|
1093
|
+
try {
|
|
1094
|
+
const plan = pnpmSpawnPlan();
|
|
1095
|
+
result = spawnSync(plan.command, fallbackAddArgs(target), {
|
|
1096
|
+
cwd: profileDir,
|
|
1097
|
+
env: pnpmGuardEnv(),
|
|
1098
|
+
shell: plan.shell,
|
|
1099
|
+
encoding: "utf8",
|
|
1100
|
+
timeout: 180000,
|
|
1101
|
+
windowsHide: true,
|
|
1102
|
+
});
|
|
1103
|
+
} catch (error) {
|
|
1104
|
+
return { exitCode: 1, output: "", error };
|
|
1105
|
+
}
|
|
1106
|
+
return {
|
|
1107
|
+
exitCode: typeof result.status === "number" ? result.status : 1,
|
|
1108
|
+
output: `${result.stdout ?? ""}${result.stderr ?? ""}`,
|
|
1109
|
+
error: result.error,
|
|
1110
|
+
};
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1027
1113
|
/**
|
|
1028
1114
|
* Run the offline reconcile install synchronously (rollback is a sync
|
|
1029
1115
|
* recovery path, called from CLI/startup contexts that cannot await). Never
|
|
@@ -1206,6 +1292,28 @@ export function rollbackPendingSnapshot(profileDir) {
|
|
|
1206
1292
|
}
|
|
1207
1293
|
}
|
|
1208
1294
|
|
|
1295
|
+
if (unsatisfied.length > 0) {
|
|
1296
|
+
// pnpm 11's headless short-circuit (node_modules/.pnpm/lock.yaml) can turn
|
|
1297
|
+
// the reconcile above into a no-op ("Already up to date") while the package
|
|
1298
|
+
// is actually gone — `--force` does not bypass it. Twice on a real profile
|
|
1299
|
+
// that left the plugin missing and dsh unable to boot. Retry the still-
|
|
1300
|
+
// missing direct dependencies one at a time with `pnpm add` (full
|
|
1301
|
+
// resolution, still offline), then put the snapshot bytes back over
|
|
1302
|
+
// whatever pnpm wrote: the add is only the means to relink node_modules,
|
|
1303
|
+
// the snapshot stays authoritative for the declaration files.
|
|
1304
|
+
for (const depName of [...unsatisfied]) {
|
|
1305
|
+
const target = fallbackAddTarget(depName, restoredDependencies[depName]);
|
|
1306
|
+
if (target === undefined) continue; // not offline-addable — fail closed below
|
|
1307
|
+
const addAttempt = runFallbackAdd(profileDir, target);
|
|
1308
|
+
if (addAttempt.exitCode === 0) {
|
|
1309
|
+
restoreProfileSnapshot(pending);
|
|
1310
|
+
if (candidateRestoredCompatible(profileDir, depName, restoredDependencies[depName])) {
|
|
1311
|
+
unsatisfied.splice(unsatisfied.indexOf(depName), 1);
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1209
1317
|
if (unsatisfied.length > 0) {
|
|
1210
1318
|
const why =
|
|
1211
1319
|
wasUpdate && pending.files?.["pnpm-lock.yaml"]?.present !== true
|
|
@@ -1217,9 +1325,10 @@ export function rollbackPendingSnapshot(profileDir) {
|
|
|
1217
1325
|
: "node_modules missing direct dependency";
|
|
1218
1326
|
const tail = String(attempt?.output ?? "").replace(/\s+/g, " ").trim().slice(-400);
|
|
1219
1327
|
// KEEP the marker + snapshot: the profile files are already restored,
|
|
1220
|
-
// so a retry (`guard recover`)
|
|
1328
|
+
// so a retry (`guard recover`), the per-package `pnpm add` above, or a
|
|
1329
|
+
// manual pnpm install finishes it.
|
|
1221
1330
|
throw new Error(
|
|
1222
|
-
`rollback restored the profile files and removed the failed install, but direct dependencies in node_modules are missing or incompatible (${unsatisfied.join(", ")}; ${why}) — the pending marker and snapshot were KEPT; re-run \`guard recover\` or run \`pnpm install --ignore-scripts --frozen-lockfile\` in ${profileDir} manually${tail ? `. pnpm output: ${tail}` : ""}`
|
|
1331
|
+
`rollback restored the profile files and removed the failed install, but direct dependencies in node_modules are missing or incompatible even after the offline reconcile and per-package add fallback (${unsatisfied.join(", ")}; ${why}) — the pending marker and snapshot were KEPT; re-run \`guard recover\` or run \`pnpm install --ignore-scripts --frozen-lockfile\` in ${profileDir} manually${tail ? `. pnpm output: ${tail}` : ""}`
|
|
1223
1332
|
);
|
|
1224
1333
|
}
|
|
1225
1334
|
|
|
@@ -1888,6 +1997,12 @@ async function selfTest() {
|
|
|
1888
1997
|
if (rep.verdict !== "blocked" || !rep.issues.some((entry) => entry.code === "unsafe-spec")) {
|
|
1889
1998
|
throw new Error("preflightInstall should reject a spec with shell metacharacters before spawning");
|
|
1890
1999
|
}
|
|
2000
|
+
// `%` 单独成案:它不是 POSIX 元字符,但 cmd 会做 %VAR% 展开,展开值
|
|
2001
|
+
// 里的分号/空格足以重塑 argv——黑名单曾漏掉它,与 cli.js 漂移过。
|
|
2002
|
+
const repPct = await preflightInstall({ profileDir: p, spec: "evil-pkg%PATH%" });
|
|
2003
|
+
if (repPct.verdict !== "blocked" || !repPct.issues.some((entry) => entry.code === "unsafe-spec")) {
|
|
2004
|
+
throw new Error("preflightInstall should reject a spec carrying cmd %VAR% expansion");
|
|
2005
|
+
}
|
|
1891
2006
|
if (process.platform === "win32") {
|
|
1892
2007
|
const repWin = await preflightInstall({ profileDir: p, spec: "file:C:\\some dir\\pkg" });
|
|
1893
2008
|
if (repWin.verdict !== "blocked" || !repWin.issues.some((entry) => entry.code === "unsafe-spec")) {
|
|
@@ -1906,6 +2021,36 @@ async function selfTest() {
|
|
|
1906
2021
|
if (!args.includes("--ignore-scripts")) throw new Error("probe args must keep install scripts disabled");
|
|
1907
2022
|
}
|
|
1908
2023
|
|
|
2024
|
+
// pnpmSpawnPlan (pure, plus a real spawn on Windows): the .cmd shim path
|
|
2025
|
+
// must carry its own quotes. Node's shell:true joins command and args
|
|
2026
|
+
// without per-argument quoting, so `D:\Program Files\nodejs\pnpm.CMD`
|
|
2027
|
+
// would be cut at the first space and cmd would answer
|
|
2028
|
+
// `'D:\Program' is not recognized` — that exact failure blocked every
|
|
2029
|
+
// preflight on a real machine (Node's default install layout has a space).
|
|
2030
|
+
{
|
|
2031
|
+
const shimRoot = join(root, "path with space");
|
|
2032
|
+
mkdirSync(shimRoot, { recursive: true });
|
|
2033
|
+
writeFileSync(join(shimRoot, "pnpm.cmd"), "@echo probe-ok\r\n");
|
|
2034
|
+
const plan = pnpmSpawnPlan({ platform: "win32", pathEnv: shimRoot });
|
|
2035
|
+
if (plan.shell !== true || plan.treeKill !== true || plan.command !== `"${join(shimRoot, "pnpm.cmd")}"`) {
|
|
2036
|
+
throw new Error(`quoted .cmd plan expected, got ${JSON.stringify(plan)}`);
|
|
2037
|
+
}
|
|
2038
|
+
if (pnpmSpawnPlan({ platform: "linux", pathEnv: shimRoot }).command !== "pnpm") {
|
|
2039
|
+
throw new Error("posix plan should spawn pnpm directly");
|
|
2040
|
+
}
|
|
2041
|
+
const noShim = pnpmSpawnPlan({ platform: "win32", pathEnv: join(root, "no-shim-here") });
|
|
2042
|
+
if (noShim.command !== "pnpm" || noShim.shell !== true || noShim.treeKill !== true) {
|
|
2043
|
+
throw new Error(`missing-shim fallback expected, got ${JSON.stringify(noShim)}`);
|
|
2044
|
+
}
|
|
2045
|
+
// 引用不是摆设:Windows 真机端到端 spawn 一轮(CI 是 Linux,只跑静态断言)。
|
|
2046
|
+
if (process.platform === "win32") {
|
|
2047
|
+
const probe = spawnSync(plan.command, ["--version"], { shell: plan.shell, encoding: "utf8", timeout: 15000 });
|
|
2048
|
+
if (probe.status !== 0 || !/probe-ok/.test(probe.stdout ?? "")) {
|
|
2049
|
+
throw new Error(`quoted shim must actually run: status=${probe.status} stdout=${JSON.stringify(probe.stdout)} stderr=${JSON.stringify(probe.stderr)}`);
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
2052
|
+
}
|
|
2053
|
+
|
|
1909
2054
|
// Rollback reconcile args/env (pure): scripts off, the restored lockfile
|
|
1910
2055
|
// authoritative, peer auto-install off, strictly offline — there is no
|
|
1911
2056
|
// online retry path or args at all, and nothing user-controlled anywhere
|
|
@@ -2014,6 +2159,91 @@ async function selfTest() {
|
|
|
2014
2159
|
if (attempts.length !== 1) throw new Error(`the rollback must run exactly ONE offline reconcile, got ${attempts.length}`);
|
|
2015
2160
|
}
|
|
2016
2161
|
|
|
2162
|
+
// The reconcile no-op trap, met twice on a real profile: pnpm 11's headless
|
|
2163
|
+
// short-circuit answers `install --frozen` with exit 0 / "Already up to
|
|
2164
|
+
// date" while the package is actually missing (--force does not bypass it),
|
|
2165
|
+
// leaving dsh unable to boot. The stub models exactly that: `install` exits
|
|
2166
|
+
// 0 and does NOTHING; only the per-package `add` fallback relinks the old
|
|
2167
|
+
// copy — and it also rewrites package.json the way a real pnpm would
|
|
2168
|
+
// ("^1.0.0"), which the snapshot-byte restore must overwrite. Expected:
|
|
2169
|
+
// reconcile attempt, add attempt, marker + snapshot cleared, node_modules
|
|
2170
|
+
// restored, and the declaration files byte-identical to the snapshot.
|
|
2171
|
+
{
|
|
2172
|
+
const p = join(root, "profiles", "update-reconcile-noop");
|
|
2173
|
+
mkdirSync(join(p, "node_modules", "good"), { recursive: true });
|
|
2174
|
+
writeFileSync(join(p, "package.json"), JSON.stringify({ dependencies: { good: "1.0.0" } }));
|
|
2175
|
+
writeFileSync(join(p, "cordis.patch.yml"), "[]\n");
|
|
2176
|
+
writeFileSync(join(p, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n\nimporters: {}\n");
|
|
2177
|
+
writeFileSync(join(p, "node_modules", "good", "package.json"), JSON.stringify({ name: "good", version: "2.0.0" }));
|
|
2178
|
+
const binDir = join(root, "stub-bin-noop-install");
|
|
2179
|
+
mkdirSync(binDir);
|
|
2180
|
+
const attemptsFile = join(binDir, "attempts.txt");
|
|
2181
|
+
const isWin = process.platform === "win32";
|
|
2182
|
+
const stubPath = join(binDir, isWin ? "pnpm.cmd" : "pnpm");
|
|
2183
|
+
writeFileSync(stubPath, isWin
|
|
2184
|
+
? `@echo off\r\nif "%1"=="add" goto add\r\necho install>> "${attemptsFile}"\r\nexit /b 0\r\n:add\r\nmkdir node_modules\\good 2>nul\r\necho {"name":"good","version":"1.0.0"}> node_modules\\good\\package.json\r\necho {"dependencies":{"good":"^^1.0.0"}}> package.json\r\necho add>> "${attemptsFile}"\r\nexit /b 0\r\n`
|
|
2185
|
+
: `#!/bin/sh\nif [ "$1" = "add" ]; then\n mkdir -p node_modules/good\n printf '%s' '{"name":"good","version":"1.0.0"}' > node_modules/good/package.json\n printf '%s' '{"dependencies":{"good":"^1.0.0"}}' > package.json\n echo add >> '${attemptsFile}'\n exit 0\nfi\necho install >> '${attemptsFile}'\nexit 0\n`);
|
|
2186
|
+
if (!isWin) chmodSync(stubPath, 0o755);
|
|
2187
|
+
const snap = createProfileSnapshot(p, { spec: "good@2.0.0" });
|
|
2188
|
+
markPendingSnapshot(snap, { spec: "good@2.0.0", preflight: { candidate: { name: "good", version: "2.0.0", kind: "plain" } } });
|
|
2189
|
+
const previousPath = process.env.PATH;
|
|
2190
|
+
process.env.PATH = `${binDir}${delimiter}${previousPath ?? ""}`;
|
|
2191
|
+
try {
|
|
2192
|
+
rollbackPendingSnapshot(p);
|
|
2193
|
+
} finally {
|
|
2194
|
+
if (previousPath === undefined) delete process.env.PATH;
|
|
2195
|
+
else process.env.PATH = previousPath;
|
|
2196
|
+
}
|
|
2197
|
+
if (readPendingSnapshot(p) !== undefined) throw new Error("an add-fallback-rescued rollback must clear the marker");
|
|
2198
|
+
if (existsSync(snap.dir)) throw new Error("an add-fallback-rescued rollback must delete the snapshot dir");
|
|
2199
|
+
if (readJson(join(p, "node_modules", "good", "package.json")).version !== "1.0.0") throw new Error("the add fallback must relink the old version");
|
|
2200
|
+
if (readJson(join(p, "package.json")).dependencies?.good !== "1.0.0") throw new Error("snapshot bytes must win over the add's manifest rewrite");
|
|
2201
|
+
const attempts = readFileSync(attemptsFile, "utf8").split(/\r?\n/).filter((line) => line.trim().length > 0);
|
|
2202
|
+
if (attempts.join(",") !== "install,add") throw new Error(`expected the no-op reconcile then exactly one add fallback, got ${attempts.join(",")}`);
|
|
2203
|
+
}
|
|
2204
|
+
|
|
2205
|
+
// The same no-op reconcile with an add that also fails (exit 1): rollback
|
|
2206
|
+
// must stay fail-closed — throw, KEEP marker + snapshot, declarations stay
|
|
2207
|
+
// at their snapshot bytes.
|
|
2208
|
+
{
|
|
2209
|
+
const p = join(root, "profiles", "update-reconcile-noop-fail");
|
|
2210
|
+
mkdirSync(join(p, "node_modules", "good"), { recursive: true });
|
|
2211
|
+
writeFileSync(join(p, "package.json"), JSON.stringify({ dependencies: { good: "1.0.0" } }));
|
|
2212
|
+
writeFileSync(join(p, "cordis.patch.yml"), "[]\n");
|
|
2213
|
+
writeFileSync(join(p, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n\nimporters: {}\n");
|
|
2214
|
+
writeFileSync(join(p, "node_modules", "good", "package.json"), JSON.stringify({ name: "good", version: "2.0.0" }));
|
|
2215
|
+
const binDir = join(root, "stub-bin-add-fails");
|
|
2216
|
+
mkdirSync(binDir);
|
|
2217
|
+
const attemptsFile = join(binDir, "attempts.txt");
|
|
2218
|
+
const isWin = process.platform === "win32";
|
|
2219
|
+
const stubPath = join(binDir, isWin ? "pnpm.cmd" : "pnpm");
|
|
2220
|
+
writeFileSync(stubPath, isWin
|
|
2221
|
+
? `@echo off\r\necho %1>> "${attemptsFile}"\r\nexit /b 1\r\n`
|
|
2222
|
+
: `#!/bin/sh\necho "$1" >> '${attemptsFile}'\nexit 1\n`);
|
|
2223
|
+
if (!isWin) chmodSync(stubPath, 0o755);
|
|
2224
|
+
const snap = createProfileSnapshot(p, { spec: "good@2.0.0" });
|
|
2225
|
+
markPendingSnapshot(snap, { spec: "good@2.0.0", preflight: { candidate: { name: "good", version: "2.0.0", kind: "plain" } } });
|
|
2226
|
+
const previousPath = process.env.PATH;
|
|
2227
|
+
process.env.PATH = `${binDir}${delimiter}${previousPath ?? ""}`;
|
|
2228
|
+
let threw = false;
|
|
2229
|
+
try {
|
|
2230
|
+
rollbackPendingSnapshot(p);
|
|
2231
|
+
} catch {
|
|
2232
|
+
threw = true;
|
|
2233
|
+
} finally {
|
|
2234
|
+
if (previousPath === undefined) delete process.env.PATH;
|
|
2235
|
+
else process.env.PATH = previousPath;
|
|
2236
|
+
}
|
|
2237
|
+
if (!threw) throw new Error("a rollback whose reconcile no-ops AND add fallback fails must throw");
|
|
2238
|
+
if (readPendingSnapshot(p)?.id !== snap.id) throw new Error("the failed rollback must KEEP the pending marker");
|
|
2239
|
+
if (!existsSync(snap.dir)) throw new Error("the failed rollback must KEEP the snapshot dir");
|
|
2240
|
+
if (readJson(join(p, "package.json")).dependencies?.good !== "1.0.0") throw new Error("the manifest must stay restored when the add fallback fails");
|
|
2241
|
+
const attempts = readFileSync(attemptsFile, "utf8").split(/\r?\n/).filter((line) => line.trim().length > 0);
|
|
2242
|
+
if (attempts.join(",") !== "install,add") throw new Error(`expected exactly one reconcile and one add attempt, got ${attempts.join(",")}`);
|
|
2243
|
+
rmSync(pendingPath(p), { force: true });
|
|
2244
|
+
rmSync(snap.dir, { recursive: true, force: true });
|
|
2245
|
+
}
|
|
2246
|
+
|
|
2017
2247
|
// The same interrupted update, but the reconcile never relinks the old
|
|
2018
2248
|
// copy and only an ANCESTOR node_modules still provides a satisfying
|
|
2019
2249
|
// version. Falling back to Node resolution would accept that copy — the
|
package/src/index.js
CHANGED
|
@@ -144,6 +144,13 @@ export function computeProfileFingerprint(profileDir) {
|
|
|
144
144
|
const compatCache = new Map();
|
|
145
145
|
const COMPAT_TTL = 600000;
|
|
146
146
|
|
|
147
|
+
// When this plugin instance loaded — for a host process this is effectively the
|
|
148
|
+
// host's start time (the loader mounts plugins at boot). Sent with `jobs` so a
|
|
149
|
+
// remounted client can tell "completed, restart still pending" from "completed
|
|
150
|
+
// and the restart already happened": a finishedAt older than this value means
|
|
151
|
+
// the task's Restart-dsh button has already done its job.
|
|
152
|
+
const pluginLoadedAt = Date.now();
|
|
153
|
+
|
|
147
154
|
function compatCacheGet(key) {
|
|
148
155
|
const cached = compatCache.get(key);
|
|
149
156
|
if (cached === undefined) return undefined;
|
|
@@ -918,6 +925,13 @@ export function createJobTracker({ producerFactory } = {}) {
|
|
|
918
925
|
const record = records.get(String(jobId));
|
|
919
926
|
if (record === undefined) throw new Error(`unknown install job ${JSON.stringify(String(jobId))}`);
|
|
920
927
|
const isSameSession = record.surface !== "browser" || (record.session !== "" && record.session === session);
|
|
928
|
+
const delta = typeof record.readOutput === "function"
|
|
929
|
+
? record.readOutput()
|
|
930
|
+
: typeof record.producer?.readOutput === "function" ? record.producer.readOutput() : "";
|
|
931
|
+
// Accumulate the drained deltas so `list` can restore the full log after a
|
|
932
|
+
// remount: the polling client drains destructively, so without this the
|
|
933
|
+
// backend would hold no history at all.
|
|
934
|
+
record.log = String(record.log ?? "") + delta;
|
|
921
935
|
return {
|
|
922
936
|
snapshot: {
|
|
923
937
|
id: record.id,
|
|
@@ -933,12 +947,48 @@ export function createJobTracker({ producerFactory } = {}) {
|
|
|
933
947
|
startedAt: record.startedAt,
|
|
934
948
|
finishedAt: record.finishedAt,
|
|
935
949
|
},
|
|
936
|
-
output:
|
|
937
|
-
? record.readOutput()
|
|
938
|
-
: typeof record.producer?.readOutput === "function" ? record.producer.readOutput() : "",
|
|
950
|
+
output: delta,
|
|
939
951
|
};
|
|
940
952
|
},
|
|
941
953
|
|
|
954
|
+
/**
|
|
955
|
+
* Every live record as {id, snapshot, output}, oldest first — for a freshly
|
|
956
|
+
* mounted client to restore its task panel. The install of a plugin whose
|
|
957
|
+
* bundle patch rewrites cordis.patch.yml replays the assembly tree and
|
|
958
|
+
* remounts this very UI mid-flight, dropping every React state; the backend
|
|
959
|
+
* records survive (1h/20-entry prune), so the remounted panel can show the
|
|
960
|
+
* finished task, its log, and the restart button instead of going blank
|
|
961
|
+
* with no signal at all (real incident: an update finished, the panel
|
|
962
|
+
* vanished, and the user learned it worked only by checking versions after
|
|
963
|
+
* a manual restart). Session visibility mirrors get(); dismissed records
|
|
964
|
+
* are skipped — "清空" must survive a remount too.
|
|
965
|
+
*/
|
|
966
|
+
list(session) {
|
|
967
|
+
const out = [];
|
|
968
|
+
for (const record of records.values()) {
|
|
969
|
+
if (record.dismissed === true) continue;
|
|
970
|
+
const isSameSession = record.surface !== "browser" || (record.session !== "" && record.session === session);
|
|
971
|
+
out.push({
|
|
972
|
+
id: record.id,
|
|
973
|
+
snapshot: {
|
|
974
|
+
id: record.id,
|
|
975
|
+
kind: record.kind,
|
|
976
|
+
label: record.label,
|
|
977
|
+
status: record.status,
|
|
978
|
+
detail: record.detail,
|
|
979
|
+
needsApproval: record.needsApproval,
|
|
980
|
+
approvalToken: isSameSession ? record.approvalToken : undefined,
|
|
981
|
+
extras: isSameSession ? record.extras : undefined,
|
|
982
|
+
spec: record.spec,
|
|
983
|
+
startedAt: record.startedAt,
|
|
984
|
+
finishedAt: record.finishedAt,
|
|
985
|
+
},
|
|
986
|
+
output: String(record.log ?? ""),
|
|
987
|
+
});
|
|
988
|
+
}
|
|
989
|
+
return out;
|
|
990
|
+
},
|
|
991
|
+
|
|
942
992
|
cancel(jobId, session) {
|
|
943
993
|
const record = records.get(String(jobId));
|
|
944
994
|
if (record === undefined) throw new Error(`unknown install job ${JSON.stringify(String(jobId))}`);
|
|
@@ -969,6 +1019,9 @@ export function createJobTracker({ producerFactory } = {}) {
|
|
|
969
1019
|
if (record.surface === "browser" && (record.session === "" || record.session !== session)) {
|
|
970
1020
|
return false;
|
|
971
1021
|
}
|
|
1022
|
+
// Marked, not deleted: `list` (panel restore after a remount) skips these,
|
|
1023
|
+
// so a cleared panel stays cleared across remounts.
|
|
1024
|
+
record.dismissed = true;
|
|
972
1025
|
if (record.approvalToken) {
|
|
973
1026
|
invalidateApprovalToken(record.approvalToken, record.session || undefined, record.surface);
|
|
974
1027
|
record.approvalToken = undefined;
|
|
@@ -1482,6 +1535,17 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
|
|
|
1482
1535
|
return rpcFail(error);
|
|
1483
1536
|
}
|
|
1484
1537
|
}
|
|
1538
|
+
case "jobs": {
|
|
1539
|
+
// Panel restore for a freshly mounted client: an install that rewrites
|
|
1540
|
+
// cordis.patch.yml replays the assembly tree and remounts this UI,
|
|
1541
|
+
// dropping all React state. The task records live here.
|
|
1542
|
+
try {
|
|
1543
|
+
const session = requireBrowserSession(payload?.session);
|
|
1544
|
+
return rpcOk({ jobs: tracker.list(session), hostStartedAt: pluginLoadedAt });
|
|
1545
|
+
} catch (error) {
|
|
1546
|
+
return rpcFail(error);
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1485
1549
|
case "restart": {
|
|
1486
1550
|
const profile = String(payload?.profile ?? defaultProfile).trim();
|
|
1487
1551
|
try {
|
|
@@ -2291,6 +2355,52 @@ export async function runSelfTests() {
|
|
|
2291
2355
|
const snapAfterDismiss = sessionTracker.get(sessionJobId, "session-alpha").snapshot;
|
|
2292
2356
|
check("dismiss 后 job snapshot 中 approvalToken 为 undefined", snapAfterDismiss.approvalToken === undefined);
|
|
2293
2357
|
|
|
2358
|
+
// ── 5b. list(重挂载恢复):日志累积、dismissed 过滤、session 隔离 ────────
|
|
2359
|
+
// 安装事务改写 cordis.patch.yml 会让 dsh 重放装配树、市场 UI 整体重挂载,
|
|
2360
|
+
// 前端 state 全丢。任务记录在后端活着——list 就是恢复通道:drain 过的
|
|
2361
|
+
// 日志增量必须已在后端累积成全量,「清空」过的条目不得被拉回。
|
|
2362
|
+
{
|
|
2363
|
+
const pendingLines = ["line-a\n", "line-b\n"];
|
|
2364
|
+
const lineProducer = {
|
|
2365
|
+
cancel: () => {},
|
|
2366
|
+
done: Promise.resolve({ status: "completed", detail: "done" }),
|
|
2367
|
+
readOutput: () => pendingLines.shift() ?? "",
|
|
2368
|
+
};
|
|
2369
|
+
const listTracker = createJobTracker({ producerFactory: () => lineProducer });
|
|
2370
|
+
const listJobId = listTracker.start({
|
|
2371
|
+
profile: "web",
|
|
2372
|
+
spec: "log-pkg",
|
|
2373
|
+
profileDir,
|
|
2374
|
+
surface: "browser",
|
|
2375
|
+
session: "session-alpha",
|
|
2376
|
+
});
|
|
2377
|
+
await new Promise((resolvePromise) => setImmediate(resolvePromise));
|
|
2378
|
+
const drain1 = listTracker.get(listJobId, "session-alpha").output;
|
|
2379
|
+
const drain2 = listTracker.get(listJobId, "session-alpha").output;
|
|
2380
|
+
const restoredJob = listTracker.list("session-alpha").find((entry) => entry.id === listJobId);
|
|
2381
|
+
check(
|
|
2382
|
+
"list 恢复全量日志(drain 过的增量在后端累积成完整历史)",
|
|
2383
|
+
drain1 === "line-a\n" && drain2 === "line-b\n"
|
|
2384
|
+
&& restoredJob !== undefined
|
|
2385
|
+
&& restoredJob.output === "line-a\nline-b\n"
|
|
2386
|
+
&& restoredJob.snapshot.status === "completed"
|
|
2387
|
+
&& restoredJob.snapshot.spec === "log-pkg",
|
|
2388
|
+
JSON.stringify({ drain1, drain2, restored: restoredJob?.output }),
|
|
2389
|
+
);
|
|
2390
|
+
const crossSessionJob = listTracker.list("session-beta").find((entry) => entry.id === listJobId);
|
|
2391
|
+
check(
|
|
2392
|
+
"list 对异 session 不暴露 approvalToken/extras(与 get 同规格)",
|
|
2393
|
+
crossSessionJob !== undefined
|
|
2394
|
+
&& crossSessionJob.snapshot.approvalToken === undefined
|
|
2395
|
+
&& crossSessionJob.snapshot.extras === undefined,
|
|
2396
|
+
);
|
|
2397
|
+
listTracker.dismiss(listJobId, "session-alpha");
|
|
2398
|
+
check(
|
|
2399
|
+
"dismiss 后 list 不再返回该条(「清空」跨重挂载存活)",
|
|
2400
|
+
listTracker.list("session-alpha").every((entry) => entry.id !== listJobId),
|
|
2401
|
+
);
|
|
2402
|
+
}
|
|
2403
|
+
|
|
2294
2404
|
// ── 6. Windows profile names & restart plan ──────────────────────────────
|
|
2295
2405
|
check("合法 profile 名称识别", isSafeProfileName("web", true) && isSafeProfileName("profile_1", true) && isSafeProfileName("dev-test", true));
|
|
2296
2406
|
check("Windows 尾随点拒绝", !isSafeProfileName("web.", true) && !isSafeProfileName("test..", true));
|
package/src/installer.js
CHANGED
|
@@ -16,7 +16,7 @@ import { createHash } from "node:crypto";
|
|
|
16
16
|
import { dump, load } from "js-yaml";
|
|
17
17
|
import { DEFAULT_PROFILE_BUNDLES, PROFILE_TEMPLATES, initProfile, resolveProfileDir } from "@deepseek-ai/dsh-app-boot";
|
|
18
18
|
import { describeBuildScripts, npmNameOf } from "./github.js";
|
|
19
|
-
import { commitPendingSnapshot, createProfileSnapshot, markPendingSnapshot, pnpmGuardEnv, rollbackPendingSnapshot } from "./guard.js";
|
|
19
|
+
import { commitPendingSnapshot, createProfileSnapshot, markPendingSnapshot, pnpmGuardEnv, pnpmSpawnPlan, readValidatedPendingSnapshot, rollbackPendingSnapshot } from "./guard.js";
|
|
20
20
|
|
|
21
21
|
// ── spec normalization ──────────────────────────────────────────────────────
|
|
22
22
|
|
|
@@ -1132,8 +1132,10 @@ export function createJobTracker() {
|
|
|
1132
1132
|
|
|
1133
1133
|
// Windows spawn 走 shell,spec 会被拼进 cmd 行;agent 传入的参数不可信。
|
|
1134
1134
|
// 合法的 npm 名 / github:owner\/repo / git·file·link·URL spec 都不含这些
|
|
1135
|
-
// shell
|
|
1136
|
-
|
|
1135
|
+
// shell 元字符——出现即拒绝,宁可误杀不放开命令注入面。`%` 在列:cmd 会
|
|
1136
|
+
// 做 `%VAR%` 环境变量展开,展开结果常含分号/空格,足以改变参数切分。
|
|
1137
|
+
// (cli.js 的同款黑名单一直含 %,此前三处已经漂移。)
|
|
1138
|
+
const UNSAFE_SPEC_RE = /[;&|`$()<>^%!"*\n\r]/;
|
|
1137
1139
|
|
|
1138
1140
|
/** Reject install/remove specs carrying shell metacharacters. */
|
|
1139
1141
|
export function assertSafeSpec(spec) {
|
|
@@ -1258,36 +1260,11 @@ function serializedProducer(lockKey, start) {
|
|
|
1258
1260
|
// (a .cmd shim cannot be spawned with shell:false on modern Node), cancel
|
|
1259
1261
|
// terminates the whole process tree and the done chain waits for the
|
|
1260
1262
|
// wrapper's 'close' before any rollback runs.
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
if (dir.length === 0) continue;
|
|
1267
|
-
for (const ext of extensions) {
|
|
1268
|
-
const candidate = join(dir, `${binary}${ext}`);
|
|
1269
|
-
if (existsSync(candidate)) return candidate;
|
|
1270
|
-
}
|
|
1271
|
-
}
|
|
1272
|
-
return undefined;
|
|
1273
|
-
}
|
|
1274
|
-
|
|
1275
|
-
/**
|
|
1276
|
-
* How to spawn pnpm on this platform.
|
|
1277
|
-
* @returns {{ command: string, shell: boolean, treeKill: boolean }}
|
|
1278
|
-
* treeKill marks the shell-wrapped case: cancel must taskkill /T the tree.
|
|
1279
|
-
*/
|
|
1280
|
-
function pnpmSpawnPlan({ platform = process.platform, pathEnv = process.env.PATH } = {}) {
|
|
1281
|
-
if (platform !== "win32") return { command: "pnpm", shell: false, treeKill: false };
|
|
1282
|
-
// A real .exe spawns without a shell — cancel then kills pnpm itself.
|
|
1283
|
-
const exe = findOnPath("pnpm", { platform, pathEnv, extensions: [".exe"] });
|
|
1284
|
-
if (exe !== undefined) return { command: exe, shell: false, treeKill: false };
|
|
1285
|
-
// Only the .cmd shim: Node refuses batch files with shell:false (EINVAL
|
|
1286
|
-
// since the batch-file argument-injection fix), so a cmd wrapper is
|
|
1287
|
-
// unavoidable — flag it so cancel kills the whole tree, not the wrapper.
|
|
1288
|
-
const cmd = findOnPath("pnpm", { platform, pathEnv, extensions: [".cmd"] });
|
|
1289
|
-
return { command: cmd ?? "pnpm", shell: true, treeKill: true };
|
|
1290
|
-
}
|
|
1263
|
+
//
|
|
1264
|
+
// The plan itself is pnpmSpawnPlan in guard.js (imported above) — one
|
|
1265
|
+
// implementation, not a local mirror. It quotes the .cmd path: Node's
|
|
1266
|
+
// shell:true joins without per-argument quoting, and `D:\Program Files\…`
|
|
1267
|
+
// would be cut at the first space (`'D:\Program' is not recognized`).
|
|
1291
1268
|
|
|
1292
1269
|
/**
|
|
1293
1270
|
* Terminate a shell-wrapped process tree (Windows): taskkill /T /F kills the
|
|
@@ -1467,17 +1444,38 @@ function runInstallInner({ profile, spec, allowBuildScripts, approvedProof, pref
|
|
|
1467
1444
|
// first live pnpm add runs. These are the files that decide what pnpm
|
|
1468
1445
|
// installs and what dsh loads; the marker is what lets startup/CLI recovery
|
|
1469
1446
|
// roll the profile back if the plugin proves unloadable.
|
|
1470
|
-
|
|
1447
|
+
//
|
|
1448
|
+
// An existing marker means one of two things. A needsApproval pause from a
|
|
1449
|
+
// previous attempt of THIS SAME install resumes: its snapshot keeps the
|
|
1450
|
+
// rollback target at "before this install first began", and re-snapshotting
|
|
1451
|
+
// now would capture the paused half-installed state as the rollback target.
|
|
1452
|
+
// Anything else (different spec, remove transaction, corrupt marker) is
|
|
1453
|
+
// refused — the recovery path owns it.
|
|
1454
|
+
let existingMarker;
|
|
1471
1455
|
try {
|
|
1472
|
-
|
|
1456
|
+
existingMarker = readValidatedPendingSnapshot(profileDir);
|
|
1473
1457
|
} catch (error) {
|
|
1474
|
-
return failedNow(`
|
|
1458
|
+
return failedNow(`profile has an unreadable pending marker — refusing to install ${spec} (${error.message}); run \`dsh-plugin-guard guard recover\` first`);
|
|
1475
1459
|
}
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1460
|
+
if (existingMarker !== undefined) {
|
|
1461
|
+
const previous = existingMarker.metadata?.spec ?? existingMarker.metadata?.packageName ?? "unknown";
|
|
1462
|
+
if (existingMarker.operation !== "install" || existingMarker.metadata?.spec !== spec) {
|
|
1463
|
+
return failedNow(`profile has a pending ${existingMarker.operation} transaction for ${JSON.stringify(previous)} — refusing to install ${spec}; restart dsh (startup recovery) or run \`dsh-plugin-guard guard recover\` first`);
|
|
1464
|
+
}
|
|
1465
|
+
push(`[dsh-plugin-mall] resuming the paused install transaction for ${spec} — its original snapshot stays the rollback target\n`);
|
|
1466
|
+
} else {
|
|
1467
|
+
let snapshot;
|
|
1468
|
+
try {
|
|
1469
|
+
snapshot = createProfileSnapshot(profileDir, { spec });
|
|
1470
|
+
} catch (error) {
|
|
1471
|
+
return failedNow(`cannot snapshot profile before installing ${spec}: ${error.message} — refusing to touch the profile`);
|
|
1472
|
+
}
|
|
1473
|
+
try {
|
|
1474
|
+
markPendingSnapshot(snapshot, { spec, preflight });
|
|
1475
|
+
} catch (error) {
|
|
1476
|
+
rmSync(snapshot.dir, { recursive: true, force: true });
|
|
1477
|
+
return failedNow(`cannot register the install pending marker for ${spec}: ${error.message} — refusing to touch the profile`);
|
|
1478
|
+
}
|
|
1481
1479
|
}
|
|
1482
1480
|
|
|
1483
1481
|
// Neutralize existing allowBuilds before every first pnpm add so strict-dep-builds
|
|
@@ -1710,6 +1708,19 @@ function runInstallInner({ profile, spec, allowBuildScripts, approvedProof, pref
|
|
|
1710
1708
|
}
|
|
1711
1709
|
if (result.status === "completed") {
|
|
1712
1710
|
// Keep the marker as-is
|
|
1711
|
+
} else if (Array.isArray(result.needsApproval) && result.needsApproval.length > 0) {
|
|
1712
|
+
// needsApproval is a PAUSE awaiting the user's decision, not a terminal
|
|
1713
|
+
// failure — do not roll back. Rolling back would tear out the candidate
|
|
1714
|
+
// pnpm just installed and rewrite the manifest to the old version, so
|
|
1715
|
+
// the retry's profile fingerprint and preflight report drift with the
|
|
1716
|
+
// changed on-disk state and the approval token's digest check can never
|
|
1717
|
+
// pass (real incident: dsh-better-sidebar 0.12.3 → 0.13.0 update died
|
|
1718
|
+
// exactly here, "invalid approval token: preflight report changed").
|
|
1719
|
+
// The retry's rebuild branch also needs this tree in place. Leave the
|
|
1720
|
+
// on-disk state and the marker for the token retry; an abandoned pause
|
|
1721
|
+
// is settled by startup recovery / `guard recover`, whose rollback
|
|
1722
|
+
// target is still the pre-first-attempt snapshot.
|
|
1723
|
+
push("\n[dsh-plugin-mall] install paused for build-script approval — the candidate stays installed with its scripts blocked; approve in the UI to finish, or restart dsh / run `dsh-plugin-guard guard recover` to roll back\n");
|
|
1713
1724
|
} else {
|
|
1714
1725
|
try {
|
|
1715
1726
|
rollbackPendingSnapshot(profileDir);
|
|
@@ -2051,6 +2062,17 @@ async function runTransactionFixtures() {
|
|
|
2051
2062
|
const tick = () => new Promise((resolve) => setTimeout(resolve, 1));
|
|
2052
2063
|
const flush = async (rounds = 5) => { for (let index = 0; index < rounds; index++) await tick(); };
|
|
2053
2064
|
|
|
2065
|
+
// 纯函数前置:spec 黑名单。`%` 必须在内——cmd 的 %VAR% 展开元字符,
|
|
2066
|
+
// 展开值里的分号/空格足以重塑 argv;三处黑名单曾漂移(cli.js 一直有,
|
|
2067
|
+
// installer/guard 漏过)。
|
|
2068
|
+
{
|
|
2069
|
+
const rejects = (value) => {
|
|
2070
|
+
try { assertSafeSpec(value); return false; } catch { return true; }
|
|
2071
|
+
};
|
|
2072
|
+
check("assertSafeSpec:cmd 的 %VAR% 展开元字符被拒绝", rejects("evil-pkg%PATH%"));
|
|
2073
|
+
check("assertSafeSpec:正常 spec 不受影响", !rejects("some-plugin@1.0.0") && !rejects("github:owner/repo"));
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2054
2076
|
// 0. hashPackageTree 确定性与符号链接越界防御
|
|
2055
2077
|
{
|
|
2056
2078
|
const tempDir = mkdtempSync(join(tmpdir(), "dsh-mall-selftest-hash-"));
|
|
@@ -2105,7 +2127,9 @@ async function runTransactionFixtures() {
|
|
|
2105
2127
|
}
|
|
2106
2128
|
|
|
2107
2129
|
// 1a. exit 0 + "Ignored build scripts" 且未批准:必须停在批准闸(failed +
|
|
2108
|
-
// needsApproval),携带 proof,绝不 finalize
|
|
2130
|
+
// needsApproval),携带 proof,绝不 finalize,且是暂停不是失败——现场与
|
|
2131
|
+
// marker 原样保留给带 token 的重试(回滚会让重试的 approval token 必死,
|
|
2132
|
+
// 见收尾处的真实事故注释),只 spawn 一次。
|
|
2109
2133
|
{
|
|
2110
2134
|
const { profileDir, cleanup } = makeTempProfile("ignored-gate");
|
|
2111
2135
|
try {
|
|
@@ -2130,8 +2154,10 @@ async function runTransactionFixtures() {
|
|
|
2130
2154
|
}],
|
|
2131
2155
|
});
|
|
2132
2156
|
const outcome = await producer.done;
|
|
2157
|
+
const output = producer.readOutput();
|
|
2158
|
+
const markerBefore = pendingMarkerPath(profileDir);
|
|
2133
2159
|
check(
|
|
2134
|
-
"退出码 0 + Ignored build scripts(未批准)→ 停在批准闸,返回 proof,不 finalize",
|
|
2160
|
+
"退出码 0 + Ignored build scripts(未批准)→ 停在批准闸,返回 proof,不 finalize,暂停保留 marker",
|
|
2135
2161
|
outcome.status === "failed"
|
|
2136
2162
|
&& Array.isArray(outcome.needsApproval)
|
|
2137
2163
|
&& outcome.needsApproval.some((entry) => entry.name === "node-pty")
|
|
@@ -2145,9 +2171,54 @@ async function runTransactionFixtures() {
|
|
|
2145
2171
|
&& entry.contentHash !== "0".repeat(64)
|
|
2146
2172
|
&& entry.weeklyDownloads === 123)
|
|
2147
2173
|
&& calls.length === 1
|
|
2148
|
-
&&
|
|
2149
|
-
|
|
2174
|
+
&& existsSync(markerBefore)
|
|
2175
|
+
&& /paused for build-script approval/.test(output),
|
|
2176
|
+
`status=${outcome.status} calls=${calls.length} marker=${existsSync(markerBefore)}`,
|
|
2150
2177
|
);
|
|
2178
|
+
|
|
2179
|
+
// 1a-bis. 同 spec 重试接管暂停的 marker:不再新建快照(回滚目标仍是
|
|
2180
|
+
// 第一次安装前的现场),继续 spawn pnpm;异 spec 则拒绝且不 spawn。
|
|
2181
|
+
{
|
|
2182
|
+
const snapshotRootDir = join(dirname(dirname(profileDir)), "guard", "snapshots");
|
|
2183
|
+
const snapshotsBefore = readdirSync(snapshotRootDir, { withFileTypes: true }).filter((e) => e.isDirectory()).length;
|
|
2184
|
+
const retry = scriptedSpawn([{ code: 0, out: "Ignored build scripts: node-pty@1.0.0\nDone\n" }]);
|
|
2185
|
+
const retryProducer = runInstall({
|
|
2186
|
+
profile: "p",
|
|
2187
|
+
spec: "some-plugin",
|
|
2188
|
+
preflight: preflightStub("some-plugin"),
|
|
2189
|
+
_profileDir: profileDir,
|
|
2190
|
+
_spawn: retry.spawnFn,
|
|
2191
|
+
_describe: async () => [],
|
|
2192
|
+
});
|
|
2193
|
+
const retryOutcome = await retryProducer.done;
|
|
2194
|
+
const retryOutput = retryProducer.readOutput();
|
|
2195
|
+
const snapshotsAfter = readdirSync(snapshotRootDir, { withFileTypes: true }).filter((e) => e.isDirectory()).length;
|
|
2196
|
+
check(
|
|
2197
|
+
"同 spec 重试接管暂停的 marker → 复用原快照,继续安装而非拒绝",
|
|
2198
|
+
retryOutcome.status === "failed"
|
|
2199
|
+
&& Array.isArray(retryOutcome.needsApproval)
|
|
2200
|
+
&& retry.calls.length === 1
|
|
2201
|
+
&& /resuming the paused install transaction/.test(retryOutput)
|
|
2202
|
+
&& snapshotsAfter === snapshotsBefore,
|
|
2203
|
+
`status=${retryOutcome.status} calls=${retry.calls.length} snapshots=${snapshotsBefore}->${snapshotsAfter}`,
|
|
2204
|
+
);
|
|
2205
|
+
const other = scriptedSpawn([{ code: 0, out: "Done\n" }]);
|
|
2206
|
+
const otherOutcome = await runInstall({
|
|
2207
|
+
profile: "p",
|
|
2208
|
+
spec: "another-plugin",
|
|
2209
|
+
preflight: preflightStub("another-plugin"),
|
|
2210
|
+
_profileDir: profileDir,
|
|
2211
|
+
_spawn: other.spawnFn,
|
|
2212
|
+
_describe: async () => [],
|
|
2213
|
+
}).done;
|
|
2214
|
+
check(
|
|
2215
|
+
"异 spec 遇暂停 marker → 拒绝且不 spawn",
|
|
2216
|
+
otherOutcome.status === "failed"
|
|
2217
|
+
&& /pending install transaction/.test(otherOutcome.detail ?? "")
|
|
2218
|
+
&& other.calls.length === 0,
|
|
2219
|
+
`status=${otherOutcome.status} calls=${other.calls.length}`,
|
|
2220
|
+
);
|
|
2221
|
+
}
|
|
2151
2222
|
const call = calls[0] ?? { args: [], options: {} };
|
|
2152
2223
|
check(
|
|
2153
2224
|
"实装 argv/env:strict-dep-builds + peer 关闭 + cwd/shell 正确",
|
|
@@ -2510,7 +2581,7 @@ async function runTransactionFixtures() {
|
|
|
2510
2581
|
check(
|
|
2511
2582
|
"损坏/既有 pending marker → install 不 spawn、不覆盖证据、不遗留新 snapshot",
|
|
2512
2583
|
outcome.status === "failed"
|
|
2513
|
-
&& /
|
|
2584
|
+
&& /unreadable pending marker/.test(outcome.detail ?? "")
|
|
2514
2585
|
&& calls.length === 0
|
|
2515
2586
|
&& readFileSync(marker, "utf8") === corruptBytes
|
|
2516
2587
|
&& leftoverSnapshots.length === 0,
|
|
@@ -2567,7 +2638,9 @@ async function runTransactionFixtures() {
|
|
|
2567
2638
|
}
|
|
2568
2639
|
|
|
2569
2640
|
// 4a. spawn 计划(纯函数):非 Windows 无 shell;Windows 有 .exe 则
|
|
2570
|
-
// shell:false,仅 .cmd 则 shell:true + treeKill
|
|
2641
|
+
// shell:false,仅 .cmd 则 shell:true + treeKill 且 command 自带引号
|
|
2642
|
+
// (.cmd 常在 `D:\Program Files\nodejs` 这类带空格的目录里,shell:true 下
|
|
2643
|
+
// Node 只拼空格不逐参数引用,不引用就从空格截断),全找不到回退 "pnpm"。
|
|
2571
2644
|
{
|
|
2572
2645
|
const shimDir = mkdtempSync(join(tmpdir(), "dsh-mall-selftest-path-"));
|
|
2573
2646
|
try {
|
|
@@ -2577,15 +2650,36 @@ async function runTransactionFixtures() {
|
|
|
2577
2650
|
const missingOk = planMissing.command === "pnpm" && planMissing.shell === true && planMissing.treeKill === true;
|
|
2578
2651
|
writeFileSync(join(shimDir, "pnpm.cmd"), "@echo off\r\n");
|
|
2579
2652
|
const planCmd = pnpmSpawnPlan({ platform: "win32", pathEnv: shimDir });
|
|
2580
|
-
const cmdOk = planCmd.command === join(shimDir, "pnpm.cmd") && planCmd.shell === true && planCmd.treeKill === true;
|
|
2653
|
+
const cmdOk = planCmd.command === `"${join(shimDir, "pnpm.cmd")}"` && planCmd.shell === true && planCmd.treeKill === true;
|
|
2581
2654
|
writeFileSync(join(shimDir, "pnpm.exe"), "MZ");
|
|
2582
2655
|
const planExe = pnpmSpawnPlan({ platform: "win32", pathEnv: shimDir });
|
|
2583
2656
|
const exeOk = planExe.command === join(shimDir, "pnpm.exe") && planExe.shell === false && planExe.treeKill === false;
|
|
2584
2657
|
check(
|
|
2585
|
-
"pnpmSpawnPlan:posix 直起 / win32 优先 .exe 无 shell / 仅 .cmd
|
|
2658
|
+
"pnpmSpawnPlan:posix 直起 / win32 优先 .exe 无 shell / 仅 .cmd 则带引号 + treeKill",
|
|
2586
2659
|
posixOk && missingOk && cmdOk && exeOk,
|
|
2587
2660
|
JSON.stringify({ planPosix, planMissing, planCmd, planExe }),
|
|
2588
2661
|
);
|
|
2662
|
+
// 空格目录不是假想敌:Node 默认装进 Program Files,真实机器上命令从
|
|
2663
|
+
// 空格截断曾让「'D:\Program' 不是内部或外部命令」拦下所有预检。
|
|
2664
|
+
const spaceDir = join(shimDir, "dir with space");
|
|
2665
|
+
mkdirSync(spaceDir, { recursive: true });
|
|
2666
|
+
writeFileSync(join(spaceDir, "pnpm.cmd"), "@echo probe-ok\r\n");
|
|
2667
|
+
const planSpace = pnpmSpawnPlan({ platform: "win32", pathEnv: spaceDir });
|
|
2668
|
+
check(
|
|
2669
|
+
"pnpmSpawnPlan:带空格的 .cmd 路径 → command 自带引号",
|
|
2670
|
+
planSpace.shell === true && planSpace.command === `"${join(spaceDir, "pnpm.cmd")}"`,
|
|
2671
|
+
JSON.stringify(planSpace),
|
|
2672
|
+
);
|
|
2673
|
+
// 引用必须真的可跑:Windows 真机端到端 spawn 一轮假 shim(CI 是 Linux,
|
|
2674
|
+
// 只跑静态断言;Windows 上这一条覆盖完整链路)。
|
|
2675
|
+
if (process.platform === "win32") {
|
|
2676
|
+
const probe = spawnSync(planSpace.command, ["--version"], { shell: planSpace.shell, encoding: "utf8", timeout: 15000 });
|
|
2677
|
+
check(
|
|
2678
|
+
"pnpmSpawnPlan:带引号 command 真实 spawn 不再被空格截断",
|
|
2679
|
+
probe.status === 0 && /probe-ok/.test(probe.stdout ?? ""),
|
|
2680
|
+
`status=${probe.status} stdout=${JSON.stringify((probe.stdout ?? "").slice(0, 80))} stderr=${JSON.stringify((probe.stderr ?? "").slice(0, 80))}`,
|
|
2681
|
+
);
|
|
2682
|
+
}
|
|
2589
2683
|
} finally {
|
|
2590
2684
|
rmSync(shimDir, { recursive: true, force: true });
|
|
2591
2685
|
}
|