@1e0zj/dsh-plugin-mall 0.3.1 → 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 +112 -26
- package/src/guard.js +179 -3
- package/src/index.js +113 -3
- package/src/installer.js +97 -14
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);
|
|
@@ -198,20 +217,66 @@ window.__ModuleLoader__.load({
|
|
|
198
217
|
}
|
|
199
218
|
}
|
|
200
219
|
next[id] = { status: "running", spec: spec, output: carried };
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
}, []);
|
|
220
|
+
commit(next);
|
|
221
|
+
}, [commit]);
|
|
204
222
|
var clear = useCallback(function () {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
}, []);
|
|
223
|
+
commit({});
|
|
224
|
+
}, [commit]);
|
|
208
225
|
var drop = useCallback(function (id) {
|
|
209
226
|
var next = Object.assign({}, jobsRef.current);
|
|
210
227
|
delete next[id];
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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 };
|
|
215
280
|
}
|
|
216
281
|
|
|
217
282
|
// ── plugin verification badge ───────────────────────────────────────────
|
|
@@ -425,10 +490,12 @@ window.__ModuleLoader__.load({
|
|
|
425
490
|
needsApproval: job.needsApproval,
|
|
426
491
|
busy: props.approving === job.spec,
|
|
427
492
|
onApprove: function (names) {
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
493
|
+
// 不先 drop:旧条目由重试任务的 track(carryFromId) 原子接管
|
|
494
|
+
// (撤条目 + 日志接续一拍完成)。先删的话,call("install")
|
|
495
|
+
// 要走数秒(重试还会重跑一次隔离预检),面板会空白一段,
|
|
496
|
+
// 「批准后任务消失、开始安装才冒出来」的割裂就是这么来的。
|
|
497
|
+
// 等待期间按钮由 approving 态显示「继续中…」。
|
|
498
|
+
props.onApprove(job.spec, names, job.approvalToken, id);
|
|
432
499
|
},
|
|
433
500
|
onDismiss: function () { props.onDismiss(id); },
|
|
434
501
|
})
|
|
@@ -437,11 +504,16 @@ window.__ModuleLoader__.load({
|
|
|
437
504
|
// 也不再重复一个绿色「完成」:状态行已经写了「· 完成」。
|
|
438
505
|
done && job.status === "completed" && job.kind !== "dsh-plugin-preflight"
|
|
439
506
|
? h("div", { className: "mkt_jobDone" },
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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 生效"))
|
|
445
517
|
: job.status === "failed" && !(job.needsApproval && job.needsApproval.length > 0)
|
|
446
518
|
? h("div", { className: "mkt_error" }, "失败,见下方输出")
|
|
447
519
|
: null,
|
|
@@ -567,6 +639,11 @@ window.__ModuleLoader__.load({
|
|
|
567
639
|
var _restarting = useState(false);
|
|
568
640
|
var restarting = _restarting[0];
|
|
569
641
|
var setRestarting = _restarting[1];
|
|
642
|
+
// 本次宿主进程的启动时间(jobs 端点带回):完成时间早于它的任务,
|
|
643
|
+
// 其「重启 dsh 生效」按钮已经兑现,改显示「重启已生效」。
|
|
644
|
+
var _hostStartedAt = useState(0);
|
|
645
|
+
var hostStartedAt = _hostStartedAt[0];
|
|
646
|
+
var setHostStartedAt = _hostStartedAt[1];
|
|
570
647
|
var _preflight = useState(null);
|
|
571
648
|
var preflight = _preflight[0];
|
|
572
649
|
var setPreflight = _preflight[1];
|
|
@@ -724,6 +801,14 @@ window.__ModuleLoader__.load({
|
|
|
724
801
|
|
|
725
802
|
useEffect(function () {
|
|
726
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 () { /* 恢复失败不阻塞面板 */ });
|
|
727
812
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
728
813
|
}, []);
|
|
729
814
|
|
|
@@ -748,14 +833,14 @@ window.__ModuleLoader__.load({
|
|
|
748
833
|
});
|
|
749
834
|
}, [call, track]);
|
|
750
835
|
|
|
751
|
-
var doApprove = useCallback(function (spec, names, token) {
|
|
836
|
+
var doApprove = useCallback(function (spec, names, token, carryFromId) {
|
|
752
837
|
var extra = { allowBuildScripts: names };
|
|
753
838
|
var apprToken = token || approvalTokensRef.current[spec];
|
|
754
839
|
if (apprToken) {
|
|
755
840
|
extra.approvalToken = apprToken;
|
|
756
841
|
delete approvalTokensRef.current[spec];
|
|
757
842
|
}
|
|
758
|
-
doRawInstall(spec, extra);
|
|
843
|
+
doRawInstall(spec, extra, carryFromId);
|
|
759
844
|
}, [doRawInstall]);
|
|
760
845
|
|
|
761
846
|
// 预检落定后的去向:safe 直接续装,其余出内联风险卡片。
|
|
@@ -912,6 +997,7 @@ window.__ModuleLoader__.load({
|
|
|
912
997
|
onDrop: dropJob,
|
|
913
998
|
onRestart: doRestart,
|
|
914
999
|
restarting: restarting,
|
|
1000
|
+
hostStartedAt: hostStartedAt,
|
|
915
1001
|
approving: Object.keys(installing).filter(function (s) { return installing[s]; })[0],
|
|
916
1002
|
}),
|
|
917
1003
|
preflight ? h(PreflightCard, {
|
package/src/guard.js
CHANGED
|
@@ -880,7 +880,7 @@ function sanitizeSnapshot(marker, home) {
|
|
|
880
880
|
}
|
|
881
881
|
|
|
882
882
|
/** Read and validate a profile's pending marker; undefined when none exists. */
|
|
883
|
-
function readValidatedPendingSnapshot(profileDir) {
|
|
883
|
+
export function readValidatedPendingSnapshot(profileDir) {
|
|
884
884
|
const resolved = resolve(profileDir);
|
|
885
885
|
const filePath = pendingPath(resolved);
|
|
886
886
|
if (!existsSync(filePath)) return undefined;
|
|
@@ -1042,6 +1042,74 @@ function reconcileInstallArgs() {
|
|
|
1042
1042
|
];
|
|
1043
1043
|
}
|
|
1044
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
|
+
|
|
1045
1113
|
/**
|
|
1046
1114
|
* Run the offline reconcile install synchronously (rollback is a sync
|
|
1047
1115
|
* recovery path, called from CLI/startup contexts that cannot await). Never
|
|
@@ -1224,6 +1292,28 @@ export function rollbackPendingSnapshot(profileDir) {
|
|
|
1224
1292
|
}
|
|
1225
1293
|
}
|
|
1226
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
|
+
|
|
1227
1317
|
if (unsatisfied.length > 0) {
|
|
1228
1318
|
const why =
|
|
1229
1319
|
wasUpdate && pending.files?.["pnpm-lock.yaml"]?.present !== true
|
|
@@ -1235,9 +1325,10 @@ export function rollbackPendingSnapshot(profileDir) {
|
|
|
1235
1325
|
: "node_modules missing direct dependency";
|
|
1236
1326
|
const tail = String(attempt?.output ?? "").replace(/\s+/g, " ").trim().slice(-400);
|
|
1237
1327
|
// KEEP the marker + snapshot: the profile files are already restored,
|
|
1238
|
-
// 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.
|
|
1239
1330
|
throw new Error(
|
|
1240
|
-
`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}` : ""}`
|
|
1241
1332
|
);
|
|
1242
1333
|
}
|
|
1243
1334
|
|
|
@@ -2068,6 +2159,91 @@ async function selfTest() {
|
|
|
2068
2159
|
if (attempts.length !== 1) throw new Error(`the rollback must run exactly ONE offline reconcile, got ${attempts.length}`);
|
|
2069
2160
|
}
|
|
2070
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
|
+
|
|
2071
2247
|
// The same interrupted update, but the reconcile never relinks the old
|
|
2072
2248
|
// copy and only an ANCESTOR node_modules still provides a satisfying
|
|
2073
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, pnpmSpawnPlan, rollbackPendingSnapshot } from "./guard.js";
|
|
19
|
+
import { commitPendingSnapshot, createProfileSnapshot, markPendingSnapshot, pnpmGuardEnv, pnpmSpawnPlan, readValidatedPendingSnapshot, rollbackPendingSnapshot } from "./guard.js";
|
|
20
20
|
|
|
21
21
|
// ── spec normalization ──────────────────────────────────────────────────────
|
|
22
22
|
|
|
@@ -1444,17 +1444,38 @@ function runInstallInner({ profile, spec, allowBuildScripts, approvedProof, pref
|
|
|
1444
1444
|
// first live pnpm add runs. These are the files that decide what pnpm
|
|
1445
1445
|
// installs and what dsh loads; the marker is what lets startup/CLI recovery
|
|
1446
1446
|
// roll the profile back if the plugin proves unloadable.
|
|
1447
|
-
|
|
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;
|
|
1448
1455
|
try {
|
|
1449
|
-
|
|
1456
|
+
existingMarker = readValidatedPendingSnapshot(profileDir);
|
|
1450
1457
|
} catch (error) {
|
|
1451
|
-
return failedNow(`
|
|
1458
|
+
return failedNow(`profile has an unreadable pending marker — refusing to install ${spec} (${error.message}); run \`dsh-plugin-guard guard recover\` first`);
|
|
1452
1459
|
}
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
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
|
+
}
|
|
1458
1479
|
}
|
|
1459
1480
|
|
|
1460
1481
|
// Neutralize existing allowBuilds before every first pnpm add so strict-dep-builds
|
|
@@ -1687,6 +1708,19 @@ function runInstallInner({ profile, spec, allowBuildScripts, approvedProof, pref
|
|
|
1687
1708
|
}
|
|
1688
1709
|
if (result.status === "completed") {
|
|
1689
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");
|
|
1690
1724
|
} else {
|
|
1691
1725
|
try {
|
|
1692
1726
|
rollbackPendingSnapshot(profileDir);
|
|
@@ -2093,7 +2127,9 @@ async function runTransactionFixtures() {
|
|
|
2093
2127
|
}
|
|
2094
2128
|
|
|
2095
2129
|
// 1a. exit 0 + "Ignored build scripts" 且未批准:必须停在批准闸(failed +
|
|
2096
|
-
// needsApproval),携带 proof,绝不 finalize
|
|
2130
|
+
// needsApproval),携带 proof,绝不 finalize,且是暂停不是失败——现场与
|
|
2131
|
+
// marker 原样保留给带 token 的重试(回滚会让重试的 approval token 必死,
|
|
2132
|
+
// 见收尾处的真实事故注释),只 spawn 一次。
|
|
2097
2133
|
{
|
|
2098
2134
|
const { profileDir, cleanup } = makeTempProfile("ignored-gate");
|
|
2099
2135
|
try {
|
|
@@ -2118,8 +2154,10 @@ async function runTransactionFixtures() {
|
|
|
2118
2154
|
}],
|
|
2119
2155
|
});
|
|
2120
2156
|
const outcome = await producer.done;
|
|
2157
|
+
const output = producer.readOutput();
|
|
2158
|
+
const markerBefore = pendingMarkerPath(profileDir);
|
|
2121
2159
|
check(
|
|
2122
|
-
"退出码 0 + Ignored build scripts(未批准)→ 停在批准闸,返回 proof,不 finalize",
|
|
2160
|
+
"退出码 0 + Ignored build scripts(未批准)→ 停在批准闸,返回 proof,不 finalize,暂停保留 marker",
|
|
2123
2161
|
outcome.status === "failed"
|
|
2124
2162
|
&& Array.isArray(outcome.needsApproval)
|
|
2125
2163
|
&& outcome.needsApproval.some((entry) => entry.name === "node-pty")
|
|
@@ -2133,9 +2171,54 @@ async function runTransactionFixtures() {
|
|
|
2133
2171
|
&& entry.contentHash !== "0".repeat(64)
|
|
2134
2172
|
&& entry.weeklyDownloads === 123)
|
|
2135
2173
|
&& calls.length === 1
|
|
2136
|
-
&&
|
|
2137
|
-
|
|
2174
|
+
&& existsSync(markerBefore)
|
|
2175
|
+
&& /paused for build-script approval/.test(output),
|
|
2176
|
+
`status=${outcome.status} calls=${calls.length} marker=${existsSync(markerBefore)}`,
|
|
2138
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
|
+
}
|
|
2139
2222
|
const call = calls[0] ?? { args: [], options: {} };
|
|
2140
2223
|
check(
|
|
2141
2224
|
"实装 argv/env:strict-dep-builds + peer 关闭 + cwd/shell 正确",
|
|
@@ -2498,7 +2581,7 @@ async function runTransactionFixtures() {
|
|
|
2498
2581
|
check(
|
|
2499
2582
|
"损坏/既有 pending marker → install 不 spawn、不覆盖证据、不遗留新 snapshot",
|
|
2500
2583
|
outcome.status === "failed"
|
|
2501
|
-
&& /
|
|
2584
|
+
&& /unreadable pending marker/.test(outcome.detail ?? "")
|
|
2502
2585
|
&& calls.length === 0
|
|
2503
2586
|
&& readFileSync(marker, "utf8") === corruptBytes
|
|
2504
2587
|
&& leftoverSnapshots.length === 0,
|