@1e0zj/dsh-plugin-mall 0.3.2 → 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +65 -8
- package/package.json +1 -1
- package/src/cli.js +48 -0
- package/src/client.js +12 -4
- package/src/guard.js +470 -24
- package/src/index.js +11 -1
- package/src/installer.js +133 -20
package/README.md
CHANGED
|
@@ -73,9 +73,11 @@ node <profile>/node_modules/@1e0zj/dsh-plugin-mall/src/cli.js guard launch --pro
|
|
|
73
73
|
|
|
74
74
|
**A plain `dsh web` already resolves pending installs.** The marketplace plugin runs
|
|
75
75
|
recovery when it loads: reaching that point proves dsh booted far enough to compose
|
|
76
|
-
the profile, so the pending marker is committed
|
|
77
|
-
fails validation
|
|
78
|
-
|
|
76
|
+
the profile, so the pending marker is committed — or rolled back, either because the
|
|
77
|
+
profile fails validation or because the install was left paused at the build-script
|
|
78
|
+
approval gate (an unapproved install is never committed, however healthy it looks).
|
|
79
|
+
Without this a single install would wedge the profile — every later install and
|
|
80
|
+
uninstall refuses while a marker is outstanding.
|
|
79
81
|
|
|
80
82
|
**`guard launch` is still strictly better**, because it also covers what a plain
|
|
81
83
|
start cannot: a plugin that boots fine and then crashes seconds later. It checks the
|
|
@@ -83,13 +85,42 @@ profile's pending-install marker before starting the command after `--`:
|
|
|
83
85
|
|
|
84
86
|
- **No pending install** — the command runs as-is, inheriting the terminal, and its exit code is preserved.
|
|
85
87
|
- **Clearly broken on disk** — the profile is rolled back to its pre-install snapshot *before* launch, then the command starts on the restored state.
|
|
86
|
-
- **Alive through the grace period** (default **10 seconds**; `--grace-ms <ms>` to change) — the pending snapshot is committed and the wrapper keeps waiting on the process.
|
|
88
|
+
- **Alive through the grace period** (default **10 seconds**; `--grace-ms <ms>` to change) — the pending snapshot is committed and the wrapper keeps waiting on the process. An install still paused at the approval gate is rolled back instead: surviving probation only proves the JS loads, not that you approved its build scripts.
|
|
87
89
|
- **Exits 0 inside the grace period** (one-shot command) — the pending snapshot is committed.
|
|
88
90
|
- **Crashes or exits nonzero inside the grace period** — the profile is rolled back and the *exact same command* is restarted once with the restored state (never in a loop); the restarted process's exit code is preserved. SIGINT/SIGTERM are forwarded to the child where the platform supports it; on Windows `.cmd`/`.bat` shims go through `%ComSpec%` with strict per-argument quoting.
|
|
89
91
|
|
|
90
92
|
Limitations: the grace window is the probation period — a failure that only surfaces **after** it (a plugin that crashes minutes in, or on a specific interaction) cannot be rolled back automatically, because committing deletes the active snapshot and `guard recover` then has nothing to restore. `guard validate` still diagnoses the on-disk state, but a post-commit failure needs manual repair — uninstall and reinstall the plugin, or restore a backup you kept separately. Both commands do only **static on-disk validation**; neither proves the plugin actually loads. A corrupt pending marker fails closed: the command is not launched and no unvalidated path is deleted. Preserve the snapshot and repair or restore a trustworthy marker, then run `guard recover`; quarantine the marker only after you have independently verified the profile, or decided to abandon automatic recovery.
|
|
91
93
|
|
|
92
94
|
|
|
95
|
+
## dsh won't start after an update? (affects 0.2.0 – 0.3.2, fixed in 0.3.3)
|
|
96
|
+
|
|
97
|
+
Symptom: `dsh web` exits with `cannot resolve profile bundle "<package>"`.
|
|
98
|
+
|
|
99
|
+
Cause: **updating an already-installed plugin** into a rollback (most often the
|
|
100
|
+
target carries build scripts and the flow paused at the approval card) could lose
|
|
101
|
+
the package — the rollback's reinstall of the old version was fooled by pnpm's
|
|
102
|
+
"Already up to date" short-circuit, so the plugin left node_modules while its
|
|
103
|
+
bundles declaration stayed. Fresh installs and removals are unaffected.
|
|
104
|
+
|
|
105
|
+
Recovery: add the package back exactly as the profile's `package.json` declares
|
|
106
|
+
it, then run `dsh web`.
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
# <profile> = %USERPROFILE%\.dsh\profiles\web or ~/.dsh/profiles/web
|
|
110
|
+
# npm package — package.json says "dsh-better-sidebar": "^0.13.1"
|
|
111
|
+
pnpm --dir <profile> add "dsh-better-sidebar@^0.13.1" --ignore-scripts
|
|
112
|
+
# GitHub source — package.json says "dsh-at-file": "github:omdsh-dev/dsh-at-file"
|
|
113
|
+
pnpm --dir <profile> add "github:omdsh-dev/dsh-at-file" --ignore-scripts
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
> Do not reach for 0.3.2's `dsh-plugin-guard recover` here. Its per-package
|
|
117
|
+
> fallback refuses `^` (a cmd metacharacter) and skips `github:` entirely, and
|
|
118
|
+
> pnpm writes nearly every dependency with one or the other — so the fallback
|
|
119
|
+
> never fires and recovery fails closed. Fixed on main: the fallback now pins the
|
|
120
|
+
> version (or commit) the lockfile resolved. Fixed in 0.3.3 — on that version
|
|
121
|
+
> `dsh-plugin-guard recover` does repair this.
|
|
122
|
+
|
|
123
|
+
|
|
93
124
|
## Agent tools
|
|
94
125
|
|
|
95
126
|
| Tool | What it does |
|
|
@@ -191,22 +222,48 @@ node <profile>/node_modules/@1e0zj/dsh-plugin-mall/src/cli.js guard launch --pro
|
|
|
191
222
|
```
|
|
192
223
|
|
|
193
224
|
**普通的 `dsh web` 就会了结 pending 安装。** 本插件加载时即执行恢复——能加载
|
|
194
|
-
本身就证明 dsh 已经组装好 profile、启动到了这一步,于是提交 pending
|
|
195
|
-
|
|
196
|
-
|
|
225
|
+
本身就证明 dsh 已经组装好 profile、启动到了这一步,于是提交 pending 标记;两种
|
|
226
|
+
情况改为回滚:profile 校验不过,或者那次安装停在构建脚本批准闸而未获批准
|
|
227
|
+
(没批准的安装绝不提交,哪怕它看起来一切正常)。没有这一步的话,装完一个插件
|
|
228
|
+
就会把 profile 卡住:只要标记还在,之后所有安装和卸载都会被拒绝。
|
|
197
229
|
|
|
198
230
|
**`guard launch` 仍然更强**,因为它覆盖普通启动覆盖不了的情况:插件启动正常、
|
|
199
231
|
几秒后才崩。它在启动 `--` 之后的命令前检查该 profile 的 pending 安装标记:
|
|
200
232
|
|
|
201
233
|
- **无 pending 安装** —— 命令原样运行(继承终端),透传退出码;
|
|
202
234
|
- **静态校验明显过不了** —— 启动*之前*先把 profile 回滚到安装前快照,再在恢复后的状态上启动;
|
|
203
|
-
- **活过缓刑期**(默认 **10 秒**,`--grace-ms <ms>` 可调)—— 提交 pending
|
|
235
|
+
- **活过缓刑期**(默认 **10 秒**,`--grace-ms <ms>` 可调)—— 提交 pending 快照,包装器继续守候该进程;但仍停在批准闸的安装改为回滚:活过缓刑期只证明 JS 能加载,不证明你批准了它的构建脚本;
|
|
204
236
|
- **缓刑期内以 0 退出**(一次性命令)—— 同样提交 pending 快照;
|
|
205
237
|
- **缓刑期内崩溃或非零退出** —— 回滚 profile,并用恢复后的状态**原样重启同一命令一次**(绝不循环),透传重启进程的退出码。支持的平台会把 SIGINT/SIGTERM 转发给子进程;Windows 上 `.cmd`/`.bat` 经 `%ComSpec%` 启动,逐参数严格加引号。
|
|
206
238
|
|
|
207
239
|
限制:缓刑期就是观察期——**之后**才暴露的故障(跑了几分钟才崩、或某个特定操作才触发)无法自动回滚:提交会删掉当前快照,此时 `guard recover` 已无可恢复的东西。`guard validate` 仍能诊断落盘状态,但提交之后的故障只能手工修复——卸载并重装插件(或恢复你另行保留的备份)。两条命令都只做**静态落盘校验**,都不证明插件真的能加载。pending 标记损坏时关闭式失败:不启动命令、不删除任何未校验路径。要**保留快照**、修复或恢复一个可信的标记后再跑 `guard recover`;只有在你已经独立核实过 profile、或决定放弃自动恢复之后,才去隔离(删除/移走)标记。
|
|
208
240
|
|
|
209
241
|
|
|
242
|
+
## 升级后 dsh 起不来?(0.2.0 – 0.3.2 受影响,0.3.3 已修复)
|
|
243
|
+
|
|
244
|
+
症状:`dsh web` 报 `cannot resolve profile bundle "<包名>"` 直接退出。
|
|
245
|
+
|
|
246
|
+
原因:**更新已装插件**时若走到回滚(最常见:目标插件带构建脚本、停在批准卡),
|
|
247
|
+
回滚里「装回旧版本」的一步会被 pnpm 的 "Already up to date" 空转骗过——包从
|
|
248
|
+
node_modules 消失而 bundles 声明还在。新装、卸载不受影响。
|
|
249
|
+
|
|
250
|
+
恢复:照 profile `package.json` 里原本的写法把包装回去,然后 `dsh web`。
|
|
251
|
+
|
|
252
|
+
```bash
|
|
253
|
+
# <profile> = %USERPROFILE%\.dsh\profiles\web 或 ~/.dsh/profiles/web
|
|
254
|
+
# npm 包 —— package.json 里是 "dsh-better-sidebar": "^0.13.1"
|
|
255
|
+
pnpm --dir <profile> add "dsh-better-sidebar@^0.13.1" --ignore-scripts
|
|
256
|
+
# GitHub 源 —— package.json 里是 "dsh-at-file": "github:omdsh-dev/dsh-at-file"
|
|
257
|
+
pnpm --dir <profile> add "github:omdsh-dev/dsh-at-file" --ignore-scripts
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
> 别指望 0.3.2 的 `dsh-plugin-guard recover` 修这个:它的 per-package 兜底会
|
|
261
|
+
> 拒掉 `^`(cmd 转义符),`github:` 更是整个跳过,而 pnpm 存依赖几乎不是前者
|
|
262
|
+
> 就是后者——兜底一次也不会触发,恢复只会 fail-closed。main 上已修:兜底改钉
|
|
263
|
+
> lockfile 解析出的版本(或 commit)。0.3.3 已修复——那个版本的
|
|
264
|
+
> `dsh-plugin-guard recover` 确实能修这个故障。
|
|
265
|
+
|
|
266
|
+
|
|
210
267
|
## 工作原理
|
|
211
268
|
|
|
212
269
|
- 双面包(dual-face)插件:`dsh.bundle` 半边挂在 **host 平面**(profile bundle 层),
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -40,13 +40,18 @@ import { tmpdir } from "node:os";
|
|
|
40
40
|
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
41
41
|
import { fileURLToPath } from "node:url";
|
|
42
42
|
import {
|
|
43
|
+
clearPendingApprovalPause,
|
|
43
44
|
commitPendingSnapshot,
|
|
44
45
|
createProfileSnapshot,
|
|
46
|
+
describeRollbackRebuild,
|
|
45
47
|
listPendingSnapshots,
|
|
48
|
+
markPendingApprovalPause,
|
|
46
49
|
markPendingSnapshot,
|
|
50
|
+
pendingApprovalPaused,
|
|
47
51
|
pnpmGuardEnv,
|
|
48
52
|
preflightInstall,
|
|
49
53
|
readPendingSnapshot,
|
|
54
|
+
readValidatedPendingSnapshot,
|
|
50
55
|
recoverAll,
|
|
51
56
|
recoverProfile,
|
|
52
57
|
resolveDshHome,
|
|
@@ -380,6 +385,8 @@ function cmdRecover({ home, profileDir }) {
|
|
|
380
385
|
} else if (entry.action === "rolled-back") {
|
|
381
386
|
console.log(`ROLLED BACK ${scope}: ${(entry.issues ?? []).map((issueEntry) => issueEntry.title).join("; ") || "profile would not load"}`);
|
|
382
387
|
if (entry.removed?.length) console.log(` removed from node_modules: ${entry.removed.join(", ")}`);
|
|
388
|
+
const rebuild = describeRollbackRebuild(entry.rebuild);
|
|
389
|
+
if (rebuild !== undefined) console.log(` node_modules rebuild: ${rebuild}`);
|
|
383
390
|
} else if (entry.action === "none") {
|
|
384
391
|
console.log(`no pending ${scope}`);
|
|
385
392
|
} else {
|
|
@@ -693,9 +700,25 @@ async function runPlain(command, args) {
|
|
|
693
700
|
* Commit the pending snapshot once startup probation passes. A commit failure
|
|
694
701
|
* is a warning, not a launch failure — the process is already running and
|
|
695
702
|
* healthy, and the marker simply stays pending for the next launch.
|
|
703
|
+
*
|
|
704
|
+
* An approval-paused marker must never commit: the new version sits there with
|
|
705
|
+
* its build scripts never approved, so staying alive only proves the JS loads.
|
|
706
|
+
* Roll it back to the pre-install snapshot instead (a rollback failure keeps
|
|
707
|
+
* the marker for the next attempt, same as recoverProfile).
|
|
696
708
|
*/
|
|
697
709
|
function commitLaunchSnapshot(profileDir) {
|
|
698
710
|
try {
|
|
711
|
+
let pending;
|
|
712
|
+
try {
|
|
713
|
+
pending = readValidatedPendingSnapshot(profileDir);
|
|
714
|
+
} catch {
|
|
715
|
+
pending = undefined; // unreadable marker: leave it to guard recover
|
|
716
|
+
}
|
|
717
|
+
if (pending !== undefined && pendingApprovalPaused(pending) !== undefined) {
|
|
718
|
+
rollbackPendingSnapshot(profileDir);
|
|
719
|
+
console.log(`[guard] startup probation passed, but the install was abandoned at the approval gate — profile rolled back for ${profileDir}`);
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
699
722
|
commitPendingSnapshot(profileDir);
|
|
700
723
|
console.log(`[guard] startup probation passed — pending snapshot committed for ${profileDir}`);
|
|
701
724
|
} catch (error) {
|
|
@@ -1156,6 +1179,31 @@ async function selfTest() {
|
|
|
1156
1179
|
const validated = validateInstalledProfile(profileDir);
|
|
1157
1180
|
if (validated.ok !== true) throw new Error("healthy profile should validate clean (cli)");
|
|
1158
1181
|
|
|
1182
|
+
// commitLaunchSnapshot: a marker paused at the approval gate must roll back
|
|
1183
|
+
// instead of committing even after a healthy probation — the candidate sits
|
|
1184
|
+
// there with its build scripts never approved, and committing would delete
|
|
1185
|
+
// the only rollback snapshot. Layout keeps the candidate a NEW dependency
|
|
1186
|
+
// so the rollback prunes node_modules without spawning pnpm.
|
|
1187
|
+
{
|
|
1188
|
+
const pauseProfile = join(root, "pause-home", "profiles", "web");
|
|
1189
|
+
mkdirSync(join(pauseProfile, "node_modules"), { recursive: true });
|
|
1190
|
+
writeFileSync(join(pauseProfile, "package.json"), JSON.stringify({ dependencies: {} }));
|
|
1191
|
+
writeFileSync(join(pauseProfile, "cordis.patch.yml"), "[]\n");
|
|
1192
|
+
const snap = createProfileSnapshot(pauseProfile, { fixture: true });
|
|
1193
|
+
markPendingSnapshot(snap, { spec: "good@2.0.0", preflight: { candidate: { name: "good", version: "2.0.0", kind: "bundle" } } });
|
|
1194
|
+
// 暂停现场:候选已装、声明已写,静态校验过得去——正是不许提交的原因。
|
|
1195
|
+
writeFileSync(join(pauseProfile, "package.json"), JSON.stringify({ dependencies: { good: "^2.0.0" } }));
|
|
1196
|
+
mkdirSync(join(pauseProfile, "node_modules", "good"), { recursive: true });
|
|
1197
|
+
writeFileSync(join(pauseProfile, "node_modules", "good", "package.json"), JSON.stringify({ name: "good", version: "2.0.0" }));
|
|
1198
|
+
markPendingApprovalPause(pauseProfile);
|
|
1199
|
+
commitLaunchSnapshot(pauseProfile);
|
|
1200
|
+
if (readPendingSnapshot(pauseProfile) !== undefined) throw new Error("commitLaunchSnapshot must consume (roll back) an approval-paused marker");
|
|
1201
|
+
if (existsSync(snap.dir)) throw new Error("the paused rollback must delete the snapshot dir");
|
|
1202
|
+
if (JSON.parse(readFileSync(join(pauseProfile, "package.json"), "utf8")).dependencies?.good !== undefined) {
|
|
1203
|
+
throw new Error("the paused rollback must restore the pre-install manifest");
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1159
1207
|
// guarded remove: exact official argv + shell:false runner seam, snapshot
|
|
1160
1208
|
// before mutation, immediate commit after a statically safe removal.
|
|
1161
1209
|
{
|
package/src/client.js
CHANGED
|
@@ -188,6 +188,7 @@ window.__ModuleLoader__.load({
|
|
|
188
188
|
status: snapshot.status,
|
|
189
189
|
detail: snapshot.detail,
|
|
190
190
|
needsApproval: snapshot.needsApproval,
|
|
191
|
+
staleOnRestart: snapshot.staleOnRestart,
|
|
191
192
|
approvalToken: snapshot.approvalToken,
|
|
192
193
|
kind: snapshot.kind,
|
|
193
194
|
output: output,
|
|
@@ -249,6 +250,7 @@ window.__ModuleLoader__.load({
|
|
|
249
250
|
spec: snap.spec,
|
|
250
251
|
detail: snap.detail,
|
|
251
252
|
needsApproval: snap.needsApproval,
|
|
253
|
+
staleOnRestart: snap.staleOnRestart,
|
|
252
254
|
approvalToken: snap.approvalToken,
|
|
253
255
|
kind: snap.kind,
|
|
254
256
|
output: entry.output || "",
|
|
@@ -259,9 +261,13 @@ window.__ModuleLoader__.load({
|
|
|
259
261
|
// 不在本次服务器列表里的条目属于上一次宿主会话(进程重启后
|
|
260
262
|
// tracker 清空)。已兑现的直接翻篇撤掉:completed 的重启已经
|
|
261
263
|
// 发生;needsApproval 暂停的批准卡片已随进程失效(事务由启动
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
//
|
|
264
|
+
// 恢复处置),留着只会让人点一个必然失败的按钮;staleOnRestart
|
|
265
|
+
// 的失败是「被另一个未了结事务挡住」,而那个事务必然已被启动恢复
|
|
266
|
+
// 处置——它的报错是现在时写的(「还没做完」「现在无法安装」),
|
|
267
|
+
// 留到重启之后会被当成当前状态读,而它描述的情形已经不存在。
|
|
268
|
+
// running 的标中断,别让轮询对着不存在的 id 空转。其余 failed
|
|
269
|
+
// 保留——网络、预检阻断这类原因重启后可能仍然成立,日志有排障
|
|
270
|
+
// 价值。这个判据不依赖 finishedAt(旧镜像里没有该字段)。
|
|
265
271
|
for (var key in next) {
|
|
266
272
|
if (serverIds[key]) continue;
|
|
267
273
|
var stale = next[key];
|
|
@@ -270,7 +276,9 @@ window.__ModuleLoader__.load({
|
|
|
270
276
|
status: "killed",
|
|
271
277
|
detail: "宿主进程已重启,该任务的记录随之丢失",
|
|
272
278
|
});
|
|
273
|
-
} else if (stale.status === "completed"
|
|
279
|
+
} else if (stale.status === "completed"
|
|
280
|
+
|| stale.staleOnRestart === true
|
|
281
|
+
|| (Array.isArray(stale.needsApproval) && stale.needsApproval.length > 0)) {
|
|
274
282
|
delete next[key];
|
|
275
283
|
}
|
|
276
284
|
}
|
package/src/guard.js
CHANGED
|
@@ -1008,6 +1008,32 @@ function addedDependencyNames(pending, originalDependencies = pending?.dependenc
|
|
|
1008
1008
|
// that used to live there. Restoring manifest + lockfile is then only half the
|
|
1009
1009
|
// job: the tree must be rebuilt from the restored lockfile, or the profile is
|
|
1010
1010
|
// left declaring a dependency nothing provides.
|
|
1011
|
+
//
|
|
1012
|
+
// WHEN the reconcile short-circuits (the reason the per-package add fallback
|
|
1013
|
+
// below exists at all). pnpm decides "up to date" by comparing its virtual
|
|
1014
|
+
// store bookkeeping (node_modules/.pnpm/lock.yaml) against the profile's
|
|
1015
|
+
// pnpm-lock.yaml — and reconcileNodeModules deletes node_modules/<name>
|
|
1016
|
+
// WITHOUT touching either. So the outcome hinges on how far the failed install
|
|
1017
|
+
// got before the rollback:
|
|
1018
|
+
//
|
|
1019
|
+
// pnpm add SUCCEEDED (e.g. it installed the new version and only then
|
|
1020
|
+
// stopped at the build-script approval gate) — .pnpm/lock.yaml already
|
|
1021
|
+
// records the new version, the restored pnpm-lock.yaml records the old
|
|
1022
|
+
// one, they disagree, pnpm does the work and relinks the old copy. The
|
|
1023
|
+
// fallback never runs.
|
|
1024
|
+
//
|
|
1025
|
+
// pnpm add FAILED EARLY (or never ran) — .pnpm/lock.yaml still matches the
|
|
1026
|
+
// restored pnpm-lock.yaml, so pnpm answers `install --frozen` with exit 0
|
|
1027
|
+
// and does nothing while the package it was asked about is gone. Only the
|
|
1028
|
+
// per-package add relinks it.
|
|
1029
|
+
//
|
|
1030
|
+
// This is why an approval-pause rollback is the WRONG scenario to validate the
|
|
1031
|
+
// fallback with: it is precisely the branch that never reaches it (confirmed on
|
|
1032
|
+
// a real profile — reconcile exit 0, package restored, fallback untouched). To
|
|
1033
|
+
// exercise it, reproduce the second row: install the package, leave
|
|
1034
|
+
// .pnpm/lock.yaml in agreement with pnpm-lock.yaml, mark a pending UPDATE
|
|
1035
|
+
// transaction, and roll back without running any pnpm in between. Both the `^`
|
|
1036
|
+
// range and the github: pinning paths were verified that way.
|
|
1011
1037
|
|
|
1012
1038
|
/**
|
|
1013
1039
|
* Env for any pnpm the guard (or its callers) spawns: peer auto-install stays
|
|
@@ -1062,26 +1088,86 @@ function fallbackAddArgs(target) {
|
|
|
1062
1088
|
}
|
|
1063
1089
|
|
|
1064
1090
|
/**
|
|
1065
|
-
* The
|
|
1066
|
-
*
|
|
1067
|
-
*
|
|
1068
|
-
*
|
|
1069
|
-
*
|
|
1070
|
-
*
|
|
1071
|
-
*
|
|
1091
|
+
* The lockfile's pinned resolution for one direct dependency, or undefined. In
|
|
1092
|
+
* a rollback the lockfile has just been restored from the snapshot, so this IS
|
|
1093
|
+
* the exact thing the rollback is trying to get back — not a guess. For a
|
|
1094
|
+
* semver dependency it is the resolved version (`0.13.1`); for a git-hosted one
|
|
1095
|
+
* it is the resolved tarball URL carrying the commit sha, which is precisely
|
|
1096
|
+
* what makes a moving `github:` spec pinnable.
|
|
1097
|
+
*
|
|
1098
|
+
* Only the profile's own importer (`.`) is read — a dsh profile is a single
|
|
1099
|
+
* package, never a workspace. A version carrying pnpm's peer suffix
|
|
1100
|
+
* (`1.2.3(react@18.0.0)`, emitted when a peer is resolved from inside the
|
|
1101
|
+
* project) is not a legal add spec; it is returned as-is and the caller's
|
|
1102
|
+
* assertSafeSpec rejects it on the parens, so the fallback fails closed rather
|
|
1103
|
+
* than adding something wrong. Profiles install with auto-install-peers off and
|
|
1104
|
+
* take their peers from the host, so this has not been observed in practice.
|
|
1072
1105
|
*/
|
|
1073
|
-
function
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1106
|
+
function pinnedLockfileVersion(profileDir, name) {
|
|
1107
|
+
try {
|
|
1108
|
+
const doc = load(readFileSync(join(profileDir, "pnpm-lock.yaml"), "utf8"));
|
|
1109
|
+
const version = doc?.importers?.["."]?.dependencies?.[name]?.version;
|
|
1110
|
+
return typeof version === "string" && version.length > 0 ? version : undefined;
|
|
1111
|
+
} catch {
|
|
1112
|
+
return undefined;
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
/** The target when it survives the spec blacklist, undefined when it does not. */
|
|
1117
|
+
function safeAddTarget(target) {
|
|
1079
1118
|
try {
|
|
1080
1119
|
assertSafeSpec(target);
|
|
1120
|
+
return target;
|
|
1081
1121
|
} catch {
|
|
1082
1122
|
return undefined;
|
|
1083
1123
|
}
|
|
1084
|
-
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
/**
|
|
1127
|
+
* The argv target for a fallback `pnpm add` of one restored dependency, or
|
|
1128
|
+
* undefined when that spec cannot be added offline and safely.
|
|
1129
|
+
*
|
|
1130
|
+
* - `file:`/`link:` paths add by the spec itself: a local path is not a moving
|
|
1131
|
+
* target, it names one fixed thing.
|
|
1132
|
+
* - `github:owner/repo` MUST be pinned to the lockfile's resolution and is
|
|
1133
|
+
* never added by the spec itself. A bare github spec means "whatever HEAD is
|
|
1134
|
+
* now", but a rollback needs "what I had" — and the freshest thing in pnpm's
|
|
1135
|
+
* resolution cache and store is exactly the commit the failed update just
|
|
1136
|
+
* fetched, i.e. the version being rolled back FROM. Adding the bare spec
|
|
1137
|
+
* would relink that commit, and candidateRestoredCompatible cannot catch it:
|
|
1138
|
+
* a non-semver spec has no range to check, so a present package passes on
|
|
1139
|
+
* name alone. The rollback would then clear the marker and delete the
|
|
1140
|
+
* snapshot, leaving node_modules on the rejected version, the lockfile
|
|
1141
|
+
* claiming the old one, and no recovery evidence at all — a fail-OPEN worse
|
|
1142
|
+
* than not trying. The pinned tarball URL carries the commit sha, so pnpm
|
|
1143
|
+
* either relinks that exact commit from the store or exits nonzero.
|
|
1144
|
+
* - Semver ranges: `name@range` only when the range carries no shell
|
|
1145
|
+
* metacharacters — and `^` (the near-universal pnpm save prefix!) is one
|
|
1146
|
+
* (cmd's escape character: it mangles the argv through the shell-wrapped
|
|
1147
|
+
* spawn, so assertSafeSpec refuses it). For those — including multi-clause
|
|
1148
|
+
* ranges like `^1.0.0 || ^2.0.0` — the target becomes
|
|
1149
|
+
* `name@<lockfile pinned version>`: the lockfile is the authority this
|
|
1150
|
+
* rollback just restored, so its pinned version is by definition a legal
|
|
1151
|
+
* restore target for any range.
|
|
1152
|
+
*
|
|
1153
|
+
* Everything that cannot be pinned stays fail-closed (marker + snapshot kept
|
|
1154
|
+
* for `guard recover` or manual repair), which is the whole point: an unpinned
|
|
1155
|
+
* guess is not a recovery.
|
|
1156
|
+
*/
|
|
1157
|
+
function fallbackAddTarget(name, spec, profileDir) {
|
|
1158
|
+
const range = String(spec ?? "");
|
|
1159
|
+
if (range.length === 0) return undefined;
|
|
1160
|
+
if (/^(?:file:|link:)/i.test(range)) return safeAddTarget(range);
|
|
1161
|
+
const isGit = /^github:/i.test(range);
|
|
1162
|
+
if (!isGit) {
|
|
1163
|
+
if (validRange(range) === null) return undefined;
|
|
1164
|
+
// A shell-safe range splices directly; `^` and friends fall through.
|
|
1165
|
+
const direct = safeAddTarget(`${name}@${range}`);
|
|
1166
|
+
if (direct !== undefined) return direct;
|
|
1167
|
+
}
|
|
1168
|
+
const pinned = pinnedLockfileVersion(profileDir, name);
|
|
1169
|
+
if (pinned === undefined) return undefined;
|
|
1170
|
+
return safeAddTarget(`${name}@${pinned}`);
|
|
1085
1171
|
}
|
|
1086
1172
|
|
|
1087
1173
|
/**
|
|
@@ -1263,6 +1349,14 @@ export function rollbackPendingSnapshot(profileDir) {
|
|
|
1263
1349
|
// newly added candidate (absent from the restored manifest) needs nothing
|
|
1264
1350
|
// reinstalled: removing its node_modules entry above is sufficient.
|
|
1265
1351
|
// Without a lockfile a frozen install can never succeed, so it is skipped.
|
|
1352
|
+
// What the rebuild actually did, reported back to the caller. A successful
|
|
1353
|
+
// rollback used to be completely silent: reconcile and the per-package add
|
|
1354
|
+
// leave no trace, so after the fact nobody can tell which one relinked the
|
|
1355
|
+
// package — or whether either ran at all. That matters here more than usual,
|
|
1356
|
+
// because the add fallback exists precisely for the case where reconcile
|
|
1357
|
+
// silently no-ops, and "the profile looks right afterwards" does not
|
|
1358
|
+
// distinguish the two.
|
|
1359
|
+
const rebuild = { reconcile: undefined, fallback: [] };
|
|
1266
1360
|
let attempt;
|
|
1267
1361
|
if (isRemove) {
|
|
1268
1362
|
// A failed/no-op remove commonly leaves the original direct package fully
|
|
@@ -1277,6 +1371,7 @@ export function rollbackPendingSnapshot(profileDir) {
|
|
|
1277
1371
|
} else if (wasUpdate && pending.files?.["pnpm-lock.yaml"]?.present === true) {
|
|
1278
1372
|
attempt = runReconcileInstall(profileDir);
|
|
1279
1373
|
}
|
|
1374
|
+
if (attempt !== undefined) rebuild.reconcile = { exitCode: attempt.exitCode };
|
|
1280
1375
|
|
|
1281
1376
|
// Before clearing marker/snapshot after rollback, strictly verify ALL
|
|
1282
1377
|
// direct dependencies declared by the restored manifest exist in that
|
|
@@ -1302,15 +1397,21 @@ export function rollbackPendingSnapshot(profileDir) {
|
|
|
1302
1397
|
// whatever pnpm wrote: the add is only the means to relink node_modules,
|
|
1303
1398
|
// the snapshot stays authoritative for the declaration files.
|
|
1304
1399
|
for (const depName of [...unsatisfied]) {
|
|
1305
|
-
const target = fallbackAddTarget(depName, restoredDependencies[depName]);
|
|
1306
|
-
if (target === undefined)
|
|
1400
|
+
const target = fallbackAddTarget(depName, restoredDependencies[depName], profileDir);
|
|
1401
|
+
if (target === undefined) {
|
|
1402
|
+
// Not offline-addable (an unpinnable spec, no lockfile entry) — record
|
|
1403
|
+
// the refusal too, it is the reason the throw below is about to fire.
|
|
1404
|
+
rebuild.fallback.push({ name: depName, target: undefined, exitCode: undefined, restored: false });
|
|
1405
|
+
continue; // fail closed below
|
|
1406
|
+
}
|
|
1307
1407
|
const addAttempt = runFallbackAdd(profileDir, target);
|
|
1408
|
+
let restored = false;
|
|
1308
1409
|
if (addAttempt.exitCode === 0) {
|
|
1309
1410
|
restoreProfileSnapshot(pending);
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
}
|
|
1411
|
+
restored = candidateRestoredCompatible(profileDir, depName, restoredDependencies[depName]);
|
|
1412
|
+
if (restored) unsatisfied.splice(unsatisfied.indexOf(depName), 1);
|
|
1313
1413
|
}
|
|
1414
|
+
rebuild.fallback.push({ name: depName, target, exitCode: addAttempt.exitCode, restored });
|
|
1314
1415
|
}
|
|
1315
1416
|
}
|
|
1316
1417
|
|
|
@@ -1334,7 +1435,25 @@ export function rollbackPendingSnapshot(profileDir) {
|
|
|
1334
1435
|
|
|
1335
1436
|
rmSync(pendingPath(profileDir), { force: true });
|
|
1336
1437
|
rmSync(pending.dir, { recursive: true, force: true });
|
|
1337
|
-
return pending;
|
|
1438
|
+
return { ...pending, rebuild };
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
/**
|
|
1442
|
+
* One line describing what a rollback's node_modules rebuild did, or undefined
|
|
1443
|
+
* when there was nothing to rebuild (a fresh install's rollback only prunes).
|
|
1444
|
+
* Kept next to the producer so the CLI and the plugin's startup recovery report
|
|
1445
|
+
* it identically.
|
|
1446
|
+
*/
|
|
1447
|
+
export function describeRollbackRebuild(rebuild) {
|
|
1448
|
+
if (rebuild === null || typeof rebuild !== "object") return undefined;
|
|
1449
|
+
const parts = [];
|
|
1450
|
+
if (rebuild.reconcile !== undefined) parts.push(`reconcile exit ${rebuild.reconcile.exitCode}`);
|
|
1451
|
+
for (const entry of rebuild.fallback ?? []) {
|
|
1452
|
+
parts.push(entry.target === undefined
|
|
1453
|
+
? `add ${entry.name}: refused (no pinnable offline target)`
|
|
1454
|
+
: `add ${entry.target}: exit ${entry.exitCode}${entry.restored ? ", restored" : ", NOT restored"}`);
|
|
1455
|
+
}
|
|
1456
|
+
return parts.length > 0 ? parts.join("; ") : undefined;
|
|
1338
1457
|
}
|
|
1339
1458
|
|
|
1340
1459
|
// ── pending-snapshot recovery (startup + external CLI) ───────────────────────
|
|
@@ -1426,11 +1545,149 @@ export function validateRemoveCompletion(profileDir, candidateName) {
|
|
|
1426
1545
|
* @param profileDir - the profile directory (may come straight from the marker).
|
|
1427
1546
|
* @returns {{action: "none"|"committed"|"rolled-back", issues?, removed?}}
|
|
1428
1547
|
*/
|
|
1548
|
+
// ── approval-pause mark ──────────────────────────────────────────────────────
|
|
1549
|
+
//
|
|
1550
|
+
// A needsApproval pause otherwise lives only in console output: the marker
|
|
1551
|
+
// carries no trace of it, so a restart that passes the STATIC validation would
|
|
1552
|
+
// commit the new version with its build scripts never approved — a natively
|
|
1553
|
+
// built plugin is then left installed-but-broken and the rollback snapshot is
|
|
1554
|
+
// deleted. Both recovery commit points (recoverProfile here, cli.js's
|
|
1555
|
+
// commitLaunchSnapshot) must check this mark and roll back instead.
|
|
1556
|
+
//
|
|
1557
|
+
// Deliberately NOT mirrored into snapshot.json (it is written before the
|
|
1558
|
+
// transaction begins and cannot know about a later pause) and NOT a
|
|
1559
|
+
// SNAPSHOT_VERSION bump (a bump would fail-close every marker already written
|
|
1560
|
+
// by earlier versions, pushing users from auto-recoverable to manual).
|
|
1561
|
+
// sanitizeSnapshot ignores unknown metadata fields, so a missing `paused`
|
|
1562
|
+
// simply reads as "not paused" and old markers keep their behavior. Tampering
|
|
1563
|
+
// with the mark is fail-safe: forging it forces a rollback (refuses the new
|
|
1564
|
+
// plugin); deleting it restores the pre-mark behavior.
|
|
1565
|
+
|
|
1566
|
+
/** The pause record on a validated pending marker, or undefined. */
|
|
1567
|
+
export function pendingApprovalPaused(pending) {
|
|
1568
|
+
const paused = pending?.metadata?.paused;
|
|
1569
|
+
return paused !== null && typeof paused === "object" ? paused : undefined;
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
/**
|
|
1573
|
+
* How the profile looked BEFORE a paused install began, for the one package
|
|
1574
|
+
* that install is about, or undefined when nothing is paused (or the snapshot
|
|
1575
|
+
* cannot be read, in which case callers should leave their own view alone).
|
|
1576
|
+
*
|
|
1577
|
+
* A paused transaction has already written its half: `pnpm add` swapped
|
|
1578
|
+
* node_modules/<name> to the new version and the manifest declares it, but the
|
|
1579
|
+
* build scripts were never approved, dsh has not loaded any of it, and the next
|
|
1580
|
+
* startup rolls the whole thing back. Reporting that half-state as "installed"
|
|
1581
|
+
* inverts the truth for the user — an UPDATE shows the new version number while
|
|
1582
|
+
* the old one is what is actually running and what a restart will restore. So
|
|
1583
|
+
* the marketplace lists this package the way the snapshot has it instead:
|
|
1584
|
+
*
|
|
1585
|
+
* present: true — it was already installed (an update). Show `version`, the
|
|
1586
|
+
* version the snapshot's lockfile pins: what runs now and
|
|
1587
|
+
* what a restart goes back to.
|
|
1588
|
+
* present: false — it was not installed at all (a fresh install). It should
|
|
1589
|
+
* not appear in the list; nothing about it took effect.
|
|
1590
|
+
*
|
|
1591
|
+
* @returns {{name: string, present: boolean, spec?: string, version?: string}|undefined}
|
|
1592
|
+
*/
|
|
1593
|
+
export function pausedCandidateBeforeState(profileDir) {
|
|
1594
|
+
let pending;
|
|
1595
|
+
try {
|
|
1596
|
+
pending = readValidatedPendingSnapshot(profileDir);
|
|
1597
|
+
} catch {
|
|
1598
|
+
return undefined;
|
|
1599
|
+
}
|
|
1600
|
+
if (pending === undefined || pendingApprovalPaused(pending) === undefined) return undefined;
|
|
1601
|
+
const name = pending.preflight?.candidate?.name ?? pending.candidate?.name;
|
|
1602
|
+
if (typeof name !== "string" || name.length === 0) return undefined;
|
|
1603
|
+
let spec;
|
|
1604
|
+
try {
|
|
1605
|
+
// The snapshot's manifest — not the live one, which the paused install
|
|
1606
|
+
// already rewrote — decides whether this package existed beforehand.
|
|
1607
|
+
spec = readJson(join(pending.dir, "package.json"))?.dependencies?.[name];
|
|
1608
|
+
} catch {
|
|
1609
|
+
return undefined; // unreadable snapshot: do not touch the caller's view
|
|
1610
|
+
}
|
|
1611
|
+
if (spec === undefined) return { name, present: false };
|
|
1612
|
+
// The snapshot's lockfile pins what that install would have kept running.
|
|
1613
|
+
return { name, present: true, spec, version: pinnedLockfileVersion(pending.dir, name) };
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
/**
|
|
1617
|
+
* Validate the pending marker, let `mutate` edit its metadata, write it back.
|
|
1618
|
+
* The whole read-mutate-write sits under ONE try: the marker can disappear or
|
|
1619
|
+
* be replaced between the validating read and the write (an install pausing
|
|
1620
|
+
* while startup recovery consumes the same marker), and a pause mark is never
|
|
1621
|
+
* worth turning that race into a thrown error in the middle of an install.
|
|
1622
|
+
* Both callers below promise a boolean, so the failure is reported that way.
|
|
1623
|
+
* @param mutate - returns true when it changed something worth persisting.
|
|
1624
|
+
* @returns true when the marker was rewritten; false when there was nothing to
|
|
1625
|
+
* do or anything failed — never creates or heals a marker.
|
|
1626
|
+
*/
|
|
1627
|
+
function updatePendingMarkerMetadata(profileDir, mutate) {
|
|
1628
|
+
try {
|
|
1629
|
+
if (readValidatedPendingSnapshot(profileDir) === undefined) return false;
|
|
1630
|
+
const markerPath = pendingPath(profileDir);
|
|
1631
|
+
const marker = readJson(markerPath);
|
|
1632
|
+
if (marker === null || typeof marker !== "object") return false;
|
|
1633
|
+
// Validation above already guarantees metadata is a plain object.
|
|
1634
|
+
const metadata = marker.metadata ?? {};
|
|
1635
|
+
if (mutate(metadata) !== true) return false;
|
|
1636
|
+
marker.metadata = metadata;
|
|
1637
|
+
writeFileSync(markerPath, JSON.stringify(marker, undefined, 2) + "\n");
|
|
1638
|
+
return true;
|
|
1639
|
+
} catch {
|
|
1640
|
+
return false;
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
/**
|
|
1645
|
+
* Mark the profile's existing pending marker as paused at the approval gate.
|
|
1646
|
+
* @returns true when a marker was marked; false when there is nothing to mark
|
|
1647
|
+
* (no marker) or it fails validation (fail closed — never create or heal one).
|
|
1648
|
+
*/
|
|
1649
|
+
export function markPendingApprovalPause(profileDir, reason = "paused for build-script approval") {
|
|
1650
|
+
return updatePendingMarkerMetadata(profileDir, (metadata) => {
|
|
1651
|
+
metadata.paused = { reason, at: Date.now() };
|
|
1652
|
+
return true;
|
|
1653
|
+
});
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
/**
|
|
1657
|
+
* Clear the approval-pause mark: a token retry resumed the transaction, so its
|
|
1658
|
+
* eventual completion must commit normally instead of being rolled back.
|
|
1659
|
+
* @returns true when a mark was removed, false when there was none to remove.
|
|
1660
|
+
*/
|
|
1661
|
+
export function clearPendingApprovalPause(profileDir) {
|
|
1662
|
+
return updatePendingMarkerMetadata(profileDir, (metadata) => {
|
|
1663
|
+
// Same notion of "a mark exists" as pendingApprovalPaused: a non-object
|
|
1664
|
+
// reads as not paused, so there is nothing to clear.
|
|
1665
|
+
if (metadata.paused === null || typeof metadata.paused !== "object") return false;
|
|
1666
|
+
delete metadata.paused;
|
|
1667
|
+
return true;
|
|
1668
|
+
});
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1429
1671
|
export function recoverProfile(profileDir) {
|
|
1430
1672
|
const pending = readValidatedPendingSnapshot(profileDir);
|
|
1431
1673
|
if (pending === undefined) return { action: "none" };
|
|
1432
|
-
const validation = validateInstalledProfile(profileDir);
|
|
1433
1674
|
const isRemove = pending.operation === "remove";
|
|
1675
|
+
// 批准闸暂停后被放弃:静态校验过得去也不许提交——那会把「构建脚本从未
|
|
1676
|
+
// 批准」的新版本以已提交状态留下(原生构建插件装着但坏),且快照被删、
|
|
1677
|
+
// 回滚目标消失。一律回滚到第一次安装前。
|
|
1678
|
+
const pause = pendingApprovalPaused(pending);
|
|
1679
|
+
if (!isRemove && pause !== undefined) {
|
|
1680
|
+
const rolled = rollbackPendingSnapshot(profileDir);
|
|
1681
|
+
return {
|
|
1682
|
+
action: "rolled-back",
|
|
1683
|
+
reason: "批准闸暂停后被放弃(构建脚本未获批准),已回滚到安装前状态",
|
|
1684
|
+
issues: [issue("warn", "approval-paused-abandoned", "批准闸暂停后被放弃,已回滚",
|
|
1685
|
+
`安装停在构建脚本批准处未被批准(${pause.reason}),profile 已回滚到安装前状态`)],
|
|
1686
|
+
removed: addedDependencyNames(pending),
|
|
1687
|
+
rebuild: rolled?.rebuild,
|
|
1688
|
+
};
|
|
1689
|
+
}
|
|
1690
|
+
const validation = validateInstalledProfile(profileDir);
|
|
1434
1691
|
const candidateName = pending.preflight?.candidate?.name ?? pending.candidate?.name;
|
|
1435
1692
|
const removeValidation = isRemove
|
|
1436
1693
|
? validateRemoveCompletion(pending.profileDir, candidateName)
|
|
@@ -1441,8 +1698,20 @@ export function recoverProfile(profileDir) {
|
|
|
1441
1698
|
}
|
|
1442
1699
|
const recoveryIssues = [...validation.issues, ...removeValidation.issues];
|
|
1443
1700
|
const added = isRemove ? [] : addedDependencyNames(pending);
|
|
1444
|
-
rollbackPendingSnapshot(profileDir);
|
|
1445
|
-
|
|
1701
|
+
const rolled = rollbackPendingSnapshot(profileDir);
|
|
1702
|
+
// The blockers are the reason; naming them beats the old generic wording,
|
|
1703
|
+
// which said "profile failed validation" for every rollback including the
|
|
1704
|
+
// ones that were not validation failures at all.
|
|
1705
|
+
const blockers = recoveryIssues.filter((entry) => entry.severity === "block");
|
|
1706
|
+
return {
|
|
1707
|
+
action: "rolled-back",
|
|
1708
|
+
reason: blockers.length > 0
|
|
1709
|
+
? `profile 静态校验未通过:${blockers.map((entry) => entry.title).join("; ")}`
|
|
1710
|
+
: "profile 静态校验未通过",
|
|
1711
|
+
issues: recoveryIssues,
|
|
1712
|
+
removed: added,
|
|
1713
|
+
rebuild: rolled?.rebuild,
|
|
1714
|
+
};
|
|
1446
1715
|
}
|
|
1447
1716
|
|
|
1448
1717
|
/** Recover every profile with a pending marker under a dsh home. */
|
|
@@ -1682,6 +1951,105 @@ async function selfTest() {
|
|
|
1682
1951
|
if (existsSync(snap.dir)) throw new Error("recoverProfile commit should delete the snapshot dir");
|
|
1683
1952
|
}
|
|
1684
1953
|
|
|
1954
|
+
// Approval-pause mark, part 1: a paused marker must NEVER commit on
|
|
1955
|
+
// recovery — not even when the static validation would pass (the version
|
|
1956
|
+
// sits there with its build scripts never approved; committing would drop
|
|
1957
|
+
// the only rollback snapshot). Recovery rolls back to the pre-install
|
|
1958
|
+
// state. Layout: the candidate is a NEW dependency (snapshot has none), so
|
|
1959
|
+
// the rollback only prunes node_modules and never spawns pnpm.
|
|
1960
|
+
{
|
|
1961
|
+
const p = join(root, "profiles", "approval-pause");
|
|
1962
|
+
mkdirSync(join(p, "node_modules", "good"), { recursive: true });
|
|
1963
|
+
writeFileSync(join(p, "package.json"), JSON.stringify({ dependencies: {} }));
|
|
1964
|
+
writeFileSync(join(p, "cordis.patch.yml"), "[]\n");
|
|
1965
|
+
if (markPendingApprovalPause(p) !== false) throw new Error("markPendingApprovalPause without a marker must return false, not create one");
|
|
1966
|
+
const snap = createProfileSnapshot(p, { fixture: true });
|
|
1967
|
+
markPendingSnapshot(snap, { spec: "good@2.0.0", preflight: { candidate: { name: "good", version: "2.0.0", kind: "bundle" } } });
|
|
1968
|
+
// 暂停现场:pnpm 已把候选装上、声明也写了——静态校验完全过得去,
|
|
1969
|
+
// 这正是危险所在(提交 = 脚本从未批准的版本以已提交状态留下)。
|
|
1970
|
+
writeFileSync(join(p, "package.json"), JSON.stringify({ dependencies: { good: "^2.0.0" } }));
|
|
1971
|
+
writeFileSync(join(p, "node_modules", "good", "package.json"), JSON.stringify({ name: "good", version: "2.0.0" }));
|
|
1972
|
+
if (markPendingApprovalPause(p) !== true) throw new Error("markPendingApprovalPause must mark an existing marker");
|
|
1973
|
+
const pausedMarker = readJson(pendingPath(p));
|
|
1974
|
+
if (pausedMarker?.metadata?.paused?.reason !== "paused for build-script approval") throw new Error("the pause mark must persist on the marker file");
|
|
1975
|
+
const recPaused = recoverProfile(p);
|
|
1976
|
+
if (recPaused.action !== "rolled-back") throw new Error(`a paused marker must roll back even when validation would pass, got ${recPaused.action}`);
|
|
1977
|
+
if (!recPaused.issues.some((entry) => entry.code === "approval-paused-abandoned")) throw new Error("the rollback must carry the approval-paused-abandoned issue");
|
|
1978
|
+
if (readJson(join(p, "package.json")).dependencies?.good !== undefined) throw new Error("rollback must restore the pre-install manifest (no candidate)");
|
|
1979
|
+
if (existsSync(join(p, "node_modules", "good"))) throw new Error("rollback must prune the never-approved candidate");
|
|
1980
|
+
if (readPendingSnapshot(p) !== undefined) throw new Error("the rolled-back pause must consume its marker");
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
// pausedCandidateBeforeState: what the marketplace must SHOW while an
|
|
1984
|
+
// install sits paused. The half-written profile says the new version is
|
|
1985
|
+
// installed; the truth is that nothing took effect and a restart undoes it.
|
|
1986
|
+
{
|
|
1987
|
+
// An UPDATE: the package existed before, so the list keeps showing the
|
|
1988
|
+
// version that is actually running — the snapshot's pinned one.
|
|
1989
|
+
const p = join(root, "profiles", "paused-view-update");
|
|
1990
|
+
mkdirSync(join(p, "node_modules", "good"), { recursive: true });
|
|
1991
|
+
writeFileSync(join(p, "package.json"), JSON.stringify({ dependencies: { good: "^1.0.0" } }));
|
|
1992
|
+
writeFileSync(join(p, "cordis.patch.yml"), "[]\n");
|
|
1993
|
+
writeFileSync(join(p, "pnpm-lock.yaml"), [
|
|
1994
|
+
"lockfileVersion: '9.0'", "importers:", " .:", " dependencies:",
|
|
1995
|
+
" good:", " specifier: ^1.0.0", " version: 1.0.0", "",
|
|
1996
|
+
].join("\n"));
|
|
1997
|
+
writeFileSync(join(p, "node_modules", "good", "package.json"), JSON.stringify({ name: "good", version: "1.0.0" }));
|
|
1998
|
+
const snap = createProfileSnapshot(p, { spec: "good@2.0.0" });
|
|
1999
|
+
markPendingSnapshot(snap, { spec: "good@2.0.0", preflight: { candidate: { name: "good", version: "2.0.0", kind: "plain" } } });
|
|
2000
|
+
if (pausedCandidateBeforeState(p) !== undefined) throw new Error("a marker with no pause mark must not rewrite the view");
|
|
2001
|
+
// The paused half: pnpm already swapped in 2.0.0 and the manifest says so.
|
|
2002
|
+
writeFileSync(join(p, "package.json"), JSON.stringify({ dependencies: { good: "^2.0.0" } }));
|
|
2003
|
+
writeFileSync(join(p, "node_modules", "good", "package.json"), JSON.stringify({ name: "good", version: "2.0.0" }));
|
|
2004
|
+
markPendingApprovalPause(p);
|
|
2005
|
+
const view = pausedCandidateBeforeState(p);
|
|
2006
|
+
if (view?.name !== "good" || view.present !== true) throw new Error(`a paused update must report the package as previously present, got ${JSON.stringify(view)}`);
|
|
2007
|
+
if (view.version !== "1.0.0") throw new Error(`the reported version must be the snapshot's pin (what actually runs), got ${JSON.stringify(view.version)}`);
|
|
2008
|
+
if (view.spec !== "^1.0.0") throw new Error(`the reported spec must be the snapshot's, got ${JSON.stringify(view.spec)}`);
|
|
2009
|
+
rmSync(pendingPath(p), { force: true });
|
|
2010
|
+
rmSync(snap.dir, { recursive: true, force: true });
|
|
2011
|
+
}
|
|
2012
|
+
{
|
|
2013
|
+
// A FRESH install: the package did not exist before, so it must drop out
|
|
2014
|
+
// of the list entirely — nothing about it took effect.
|
|
2015
|
+
const p = join(root, "profiles", "paused-view-fresh");
|
|
2016
|
+
mkdirSync(join(p, "node_modules"), { recursive: true });
|
|
2017
|
+
writeFileSync(join(p, "package.json"), JSON.stringify({ dependencies: {} }));
|
|
2018
|
+
writeFileSync(join(p, "cordis.patch.yml"), "[]\n");
|
|
2019
|
+
const snap = createProfileSnapshot(p, { spec: "good@2.0.0" });
|
|
2020
|
+
markPendingSnapshot(snap, { spec: "good@2.0.0", preflight: { candidate: { name: "good", version: "2.0.0", kind: "plain" } } });
|
|
2021
|
+
writeFileSync(join(p, "package.json"), JSON.stringify({ dependencies: { good: "^2.0.0" } }));
|
|
2022
|
+
mkdirSync(join(p, "node_modules", "good"), { recursive: true });
|
|
2023
|
+
writeFileSync(join(p, "node_modules", "good", "package.json"), JSON.stringify({ name: "good", version: "2.0.0" }));
|
|
2024
|
+
markPendingApprovalPause(p);
|
|
2025
|
+
const view = pausedCandidateBeforeState(p);
|
|
2026
|
+
if (view?.name !== "good" || view.present !== false) throw new Error(`a paused fresh install must report the package as absent beforehand, got ${JSON.stringify(view)}`);
|
|
2027
|
+
if (view.version !== undefined) throw new Error("an absent package has no version to show");
|
|
2028
|
+
rmSync(pendingPath(p), { force: true });
|
|
2029
|
+
rmSync(snap.dir, { recursive: true, force: true });
|
|
2030
|
+
if (pausedCandidateBeforeState(p) !== undefined) throw new Error("no marker means no view rewrite");
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
// Approval-pause mark, part 2: a cleared mark (token retry resumed and
|
|
2034
|
+
// finished the transaction) commits normally — the mark must not outlive
|
|
2035
|
+
// the transaction it belonged to.
|
|
2036
|
+
{
|
|
2037
|
+
const p = join(root, "profiles", "approval-pause-cleared");
|
|
2038
|
+
mkdirSync(join(p, "node_modules", "good"), { recursive: true });
|
|
2039
|
+
writeFileSync(join(p, "package.json"), JSON.stringify({ dependencies: { good: "1.0.0" }, dsh: { profile: { bundles: ["good"] } } }));
|
|
2040
|
+
writeFileSync(join(p, "cordis.patch.yml"), "[]\n");
|
|
2041
|
+
writeFileSync(join(p, "node_modules", "good", "package.json"), JSON.stringify({ name: "good", version: "1.0.0", dsh: { bundle: { patch: "./cordis.patch.yml" } } }));
|
|
2042
|
+
writeFileSync(join(p, "node_modules", "good", "cordis.patch.yml"), "- insert:\n - id: good\n name: good\n");
|
|
2043
|
+
const snap = createProfileSnapshot(p, { fixture: true });
|
|
2044
|
+
markPendingSnapshot(snap, { spec: "good", preflight: { candidate: { name: "good", version: "1.0.0", kind: "bundle" } } });
|
|
2045
|
+
markPendingApprovalPause(p);
|
|
2046
|
+
if (clearPendingApprovalPause(p) !== true) throw new Error("clearPendingApprovalPause must remove an existing mark");
|
|
2047
|
+
const rec = recoverProfile(p);
|
|
2048
|
+
if (rec.action !== "committed") throw new Error(`after the mark is cleared a healthy install must commit, got ${rec.action}`);
|
|
2049
|
+
if (readPendingSnapshot(p) !== undefined) throw new Error("the commit must clear the marker");
|
|
2050
|
+
if (clearPendingApprovalPause(p) !== false) throw new Error("clearPendingApprovalPause without a mark must return false");
|
|
2051
|
+
}
|
|
2052
|
+
|
|
1685
2053
|
// Remove rollback, no-op failure: the official command failed before
|
|
1686
2054
|
// touching the package. Rollback must preserve the healthy direct package
|
|
1687
2055
|
// and must not run the install/update path's candidate pruning logic.
|
|
@@ -2021,6 +2389,69 @@ async function selfTest() {
|
|
|
2021
2389
|
if (!args.includes("--ignore-scripts")) throw new Error("probe args must keep install scripts disabled");
|
|
2022
2390
|
}
|
|
2023
2391
|
|
|
2392
|
+
// fallbackAddTarget (pure): what one restored dependency may be offline
|
|
2393
|
+
// re-added as. `^` ranges (pnpm's near-universal save prefix) cannot be
|
|
2394
|
+
// spliced into a shell-wrapped argv (cmd eats the caret), so they resolve
|
|
2395
|
+
// to the lockfile's pinned version — exactly what a rollback is restoring
|
|
2396
|
+
// to. A `github:` spec resolves to its pinned tarball URL for a different
|
|
2397
|
+
// and sharper reason: the bare spec means "HEAD now", and the freshest
|
|
2398
|
+
// thing in pnpm's cache/store is the commit the failed update just fetched,
|
|
2399
|
+
// so adding it bare would relink the very version being rolled back FROM —
|
|
2400
|
+
// and a non-semver spec has no range for candidateRestoredCompatible to
|
|
2401
|
+
// check, so that wrong copy would pass on name alone, clear the marker and
|
|
2402
|
+
// delete the snapshot. Pinning is what keeps the fallback fail-closed.
|
|
2403
|
+
{
|
|
2404
|
+
const lockRoot = join(root, "profiles", "fbtarget");
|
|
2405
|
+
mkdirSync(lockRoot, { recursive: true });
|
|
2406
|
+
const tarball = "https://codeload.github.com/owner/repo/tar.gz/898369ece56ae6ec41afd8e014f187bb5b723409";
|
|
2407
|
+
writeFileSync(join(lockRoot, "pnpm-lock.yaml"), [
|
|
2408
|
+
"lockfileVersion: '9.0'",
|
|
2409
|
+
"importers:",
|
|
2410
|
+
" .:",
|
|
2411
|
+
" dependencies:",
|
|
2412
|
+
" good:",
|
|
2413
|
+
" specifier: ^1.0.0",
|
|
2414
|
+
" version: 1.0.0",
|
|
2415
|
+
" hosted:",
|
|
2416
|
+
" specifier: github:owner/repo",
|
|
2417
|
+
` version: ${tarball}`,
|
|
2418
|
+
" peered:",
|
|
2419
|
+
" specifier: ^1.0.0",
|
|
2420
|
+
" version: 1.0.0(@deepseek-ai/cordis@4.0.1)",
|
|
2421
|
+
"",
|
|
2422
|
+
].join("\n"));
|
|
2423
|
+
if (fallbackAddTarget("good", "1.0.0", lockRoot) !== "good@1.0.0") throw new Error("a plain range splices directly");
|
|
2424
|
+
if (fallbackAddTarget("good", "^1.0.0", lockRoot) !== "good@1.0.0") throw new Error("a ^-range must resolve to the lockfile pinned version");
|
|
2425
|
+
if (fallbackAddTarget("good", "^1.0.0", join(root, "profiles", "no-lock-here")) !== undefined) {
|
|
2426
|
+
throw new Error("a ^-range without a readable lockfile must stay fail-closed");
|
|
2427
|
+
}
|
|
2428
|
+
// A bare github spec must NEVER be added as itself: pinning to the
|
|
2429
|
+
// lockfile's tarball URL is the only thing that names the old commit.
|
|
2430
|
+
if (fallbackAddTarget("hosted", "github:owner/repo", lockRoot) !== `hosted@${tarball}`) {
|
|
2431
|
+
throw new Error("a github spec must resolve to the lockfile pinned tarball, never to the moving bare spec");
|
|
2432
|
+
}
|
|
2433
|
+
if (fallbackAddTarget("hosted", "github:owner/repo", join(root, "profiles", "no-lock-here")) !== undefined) {
|
|
2434
|
+
throw new Error("a github spec without a readable lockfile must stay fail-closed, not fall back to the bare spec");
|
|
2435
|
+
}
|
|
2436
|
+
if (fallbackAddTarget("absent", "github:owner/absent", lockRoot) !== undefined) {
|
|
2437
|
+
throw new Error("a github spec with no lockfile entry must stay fail-closed");
|
|
2438
|
+
}
|
|
2439
|
+
// pnpm's peer suffix is not a legal add spec — the parens hit the spec
|
|
2440
|
+
// blacklist, so the fallback fails closed instead of adding something odd.
|
|
2441
|
+
if (fallbackAddTarget("peered", "^1.0.0", lockRoot) !== undefined) {
|
|
2442
|
+
throw new Error("a peer-suffixed pinned version must stay fail-closed");
|
|
2443
|
+
}
|
|
2444
|
+
if (fallbackAddTarget("good", "file:D:\\pkg.tgz", lockRoot) !== "file:D:\\pkg.tgz") throw new Error("local file target expected");
|
|
2445
|
+
// 多区间 range 直拼必被拒(空格/管道),但 lockfile 的 pinned 对任何
|
|
2446
|
+
// range 都是合法恢复目标(lockfile 即权威)——同样走 pinned,
|
|
2447
|
+
// 只有 lockfile 不可读/无该条目才 fail-closed。
|
|
2448
|
+
if (fallbackAddTarget("good", "^1.0.0 || ^2.0.0", lockRoot) !== "good@1.0.0") throw new Error("a multi-clause range must resolve to the lockfile pinned version");
|
|
2449
|
+
if (fallbackAddTarget("good", "^1.0.0 || ^2.0.0", join(root, "profiles", "no-lock-here")) !== undefined) {
|
|
2450
|
+
throw new Error("a multi-clause range without a readable lockfile must stay fail-closed");
|
|
2451
|
+
}
|
|
2452
|
+
if (fallbackAddTarget("good", "", lockRoot) !== undefined) throw new Error("an empty spec has no target");
|
|
2453
|
+
}
|
|
2454
|
+
|
|
2024
2455
|
// pnpmSpawnPlan (pure, plus a real spawn on Windows): the .cmd shim path
|
|
2025
2456
|
// must carry its own quotes. Node's shell:true joins command and args
|
|
2026
2457
|
// without per-argument quoting, so `D:\Program Files\nodejs\pnpm.CMD`
|
|
@@ -2188,12 +2619,27 @@ async function selfTest() {
|
|
|
2188
2619
|
markPendingSnapshot(snap, { spec: "good@2.0.0", preflight: { candidate: { name: "good", version: "2.0.0", kind: "plain" } } });
|
|
2189
2620
|
const previousPath = process.env.PATH;
|
|
2190
2621
|
process.env.PATH = `${binDir}${delimiter}${previousPath ?? ""}`;
|
|
2622
|
+
let rolled;
|
|
2191
2623
|
try {
|
|
2192
|
-
rollbackPendingSnapshot(p);
|
|
2624
|
+
rolled = rollbackPendingSnapshot(p);
|
|
2193
2625
|
} finally {
|
|
2194
2626
|
if (previousPath === undefined) delete process.env.PATH;
|
|
2195
2627
|
else process.env.PATH = previousPath;
|
|
2196
2628
|
}
|
|
2629
|
+
// The rebuild report is the ONLY way to tell "reconcile relinked it" from
|
|
2630
|
+
// "reconcile no-opped and the add fallback saved it" after the fact —
|
|
2631
|
+
// both leave an identical-looking profile behind.
|
|
2632
|
+
if (rolled?.rebuild?.reconcile?.exitCode !== 0) throw new Error("the rebuild report must record the no-op reconcile and its exit code");
|
|
2633
|
+
if (rolled.rebuild.fallback.length !== 1) throw new Error(`the rebuild report must record exactly one fallback add, got ${rolled.rebuild.fallback.length}`);
|
|
2634
|
+
const [addReport] = rolled.rebuild.fallback;
|
|
2635
|
+
if (addReport.name !== "good" || addReport.target !== "good@1.0.0" || addReport.exitCode !== 0 || addReport.restored !== true) {
|
|
2636
|
+
throw new Error(`the fallback report must name the package, target, exit code and outcome, got ${JSON.stringify(addReport)}`);
|
|
2637
|
+
}
|
|
2638
|
+
const described = describeRollbackRebuild(rolled.rebuild);
|
|
2639
|
+
if (!/reconcile exit 0/.test(described) || !/add good@1\.0\.0: exit 0, restored/.test(described)) {
|
|
2640
|
+
throw new Error(`describeRollbackRebuild must render both steps, got ${JSON.stringify(described)}`);
|
|
2641
|
+
}
|
|
2642
|
+
if (describeRollbackRebuild(undefined) !== undefined) throw new Error("no rebuild means no line to print");
|
|
2197
2643
|
if (readPendingSnapshot(p) !== undefined) throw new Error("an add-fallback-rescued rollback must clear the marker");
|
|
2198
2644
|
if (existsSync(snap.dir)) throw new Error("an add-fallback-rescued rollback must delete the snapshot dir");
|
|
2199
2645
|
if (readJson(join(p, "node_modules", "good", "package.json")).version !== "1.0.0") throw new Error("the add fallback must relink the old version");
|
package/src/index.js
CHANGED
|
@@ -25,7 +25,7 @@ import { tmpdir } from "node:os";
|
|
|
25
25
|
import { resolveProfileDir } from "@deepseek-ai/dsh-app-boot";
|
|
26
26
|
import { repoInfo, searchPlugins, verifyPlugins, cachedRepoManifest, fetchRawFile, preferNpmSpec, npmPackageInfo, compareVersions, assertSafeToInstall, mapLimit, NETWORK_CONCURRENCY } from "./github.js";
|
|
27
27
|
import { ensureProfile, listInstalled, normalizeSpec, runInstall, runRemove, assertSafeSpec, resolveRegistry, serializeCanonicalProof, persistPluginDisabled } from "./installer.js";
|
|
28
|
-
import { preflightInstall, inspectRemoteCandidate, recoverProfile } from "./guard.js";
|
|
28
|
+
import { preflightInstall, inspectRemoteCandidate, recoverProfile, describeRollbackRebuild } from "./guard.js";
|
|
29
29
|
|
|
30
30
|
export const name = "@1e0zj/dsh-plugin-mall";
|
|
31
31
|
// `loader` 用来读装配树、并对单个 entry 做热开关(entry.update)。读法照抄
|
|
@@ -829,6 +829,9 @@ export function createJobTracker({ producerFactory } = {}) {
|
|
|
829
829
|
record.status = status;
|
|
830
830
|
record.detail = outcome?.detail;
|
|
831
831
|
record.needsApproval = outcome?.needsApproval;
|
|
832
|
+
// 原因活不过一次重启的失败(被别的未了结事务挡住):浏览器据此在
|
|
833
|
+
// 重启后撤掉记录,而不是把一段现在时的描述留在面板上当现状读。
|
|
834
|
+
record.staleOnRestart = outcome?.staleOnRestart === true;
|
|
832
835
|
record.finishedAt = Date.now();
|
|
833
836
|
|
|
834
837
|
if (status === "completed") {
|
|
@@ -940,6 +943,7 @@ export function createJobTracker({ producerFactory } = {}) {
|
|
|
940
943
|
status: record.status,
|
|
941
944
|
detail: record.detail,
|
|
942
945
|
needsApproval: record.needsApproval,
|
|
946
|
+
staleOnRestart: record.staleOnRestart,
|
|
943
947
|
approvalToken: isSameSession ? record.approvalToken : undefined,
|
|
944
948
|
// extras(如预检结论)同样只对同一 session 可见,与 approvalToken 同规格。
|
|
945
949
|
extras: isSameSession ? record.extras : undefined,
|
|
@@ -977,6 +981,7 @@ export function createJobTracker({ producerFactory } = {}) {
|
|
|
977
981
|
status: record.status,
|
|
978
982
|
detail: record.detail,
|
|
979
983
|
needsApproval: record.needsApproval,
|
|
984
|
+
staleOnRestart: record.staleOnRestart,
|
|
980
985
|
approvalToken: isSameSession ? record.approvalToken : undefined,
|
|
981
986
|
extras: isSameSession ? record.extras : undefined,
|
|
982
987
|
spec: record.spec,
|
|
@@ -1636,6 +1641,11 @@ export function apply(ctx, config = {}) {
|
|
|
1636
1641
|
console.log(`[dsh-plugin-mall] startup recovery: committed the pending install for profile "${defaultProfile}"`);
|
|
1637
1642
|
} else if (result.action === "rolled-back") {
|
|
1638
1643
|
console.warn(`[dsh-plugin-mall] startup recovery: rolled back the pending install for profile "${defaultProfile}" — ${result.reason ?? "profile failed validation"}`);
|
|
1644
|
+
// What the rebuild did, when it did anything. A rollback that relinked a
|
|
1645
|
+
// package used to be silent, so a reconcile that silently no-opped and a
|
|
1646
|
+
// fallback add that saved the profile looked exactly alike afterwards.
|
|
1647
|
+
const rebuild = describeRollbackRebuild(result.rebuild);
|
|
1648
|
+
if (rebuild !== undefined) console.warn(`[dsh-plugin-mall] startup recovery: node_modules rebuild — ${rebuild}`);
|
|
1639
1649
|
}
|
|
1640
1650
|
} catch (error) {
|
|
1641
1651
|
// 恢复失败绝不能拖垮插件加载:报出来,让市场照常可用(用户还能手动
|
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, readValidatedPendingSnapshot, rollbackPendingSnapshot } from "./guard.js";
|
|
19
|
+
import { clearPendingApprovalPause, commitPendingSnapshot, createProfileSnapshot, markPendingApprovalPause, markPendingSnapshot, pausedCandidateBeforeState, pendingApprovalPaused, pnpmGuardEnv, pnpmSpawnPlan, readValidatedPendingSnapshot, rollbackPendingSnapshot } from "./guard.js";
|
|
20
20
|
|
|
21
21
|
// ── spec normalization ──────────────────────────────────────────────────────
|
|
22
22
|
|
|
@@ -188,7 +188,20 @@ export function reconcileBundles(profileDir, beforeDeps = new Set()) {
|
|
|
188
188
|
return result;
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
-
/**
|
|
191
|
+
/**
|
|
192
|
+
* List a profile's installed plugins (dependencies with classification +
|
|
193
|
+
* version).
|
|
194
|
+
*
|
|
195
|
+
* An install paused at the build-script approval gate is reported as the
|
|
196
|
+
* snapshot has it, not as the half-written profile has it: the candidate's
|
|
197
|
+
* scripts were never approved, dsh has not loaded it, and the next startup
|
|
198
|
+
* rolls it back, so calling it "installed" tells the user the opposite of what
|
|
199
|
+
* is true — an update would show the NEW version while the old one is what is
|
|
200
|
+
* actually running and what a restart restores. A paused fresh install drops
|
|
201
|
+
* out of the list entirely. Every consumer goes through here (the browser's
|
|
202
|
+
* installed panel, the `updates` check that decides whether an update button
|
|
203
|
+
* appears, and the `market_installed` agent tool), so they all agree.
|
|
204
|
+
*/
|
|
192
205
|
export function listInstalled(profile) {
|
|
193
206
|
const dir = resolveProfileDir(profile);
|
|
194
207
|
const manifestPath = join(dir, "package.json");
|
|
@@ -209,7 +222,13 @@ export function listInstalled(profile) {
|
|
|
209
222
|
}
|
|
210
223
|
return { name, version, kind };
|
|
211
224
|
});
|
|
212
|
-
|
|
225
|
+
const before = pausedCandidateBeforeState(dir);
|
|
226
|
+
if (before === undefined) return { dir, deps };
|
|
227
|
+
if (!before.present) return { dir, deps: deps.filter((dep) => dep.name !== before.name) };
|
|
228
|
+
return {
|
|
229
|
+
dir,
|
|
230
|
+
deps: deps.map((dep) => (dep.name === before.name ? { ...dep, version: before.version ?? "?" } : dep)),
|
|
231
|
+
};
|
|
213
232
|
}
|
|
214
233
|
|
|
215
234
|
// ── npm registry resolution ─────────────────────────────────────────────────
|
|
@@ -1094,6 +1113,9 @@ export function createJobTracker() {
|
|
|
1094
1113
|
// 待批准的构建脚本清单:浏览器侧据此渲染「允许并继续」,没有它就只有
|
|
1095
1114
|
// 一段文本,用户看不出要批准的到底是什么。
|
|
1096
1115
|
record.needsApproval = outcome.needsApproval;
|
|
1116
|
+
// 这次失败的原因活不过一次重启(见 failedNow),浏览器据此在重启后
|
|
1117
|
+
// 撤掉记录,而不是把一段现在时的描述当成当前状态留在面板上。
|
|
1118
|
+
record.staleOnRestart = outcome.staleOnRestart === true;
|
|
1097
1119
|
record.finishedAt = Date.now();
|
|
1098
1120
|
onSettled?.(outcome);
|
|
1099
1121
|
});
|
|
@@ -1112,6 +1134,7 @@ export function createJobTracker() {
|
|
|
1112
1134
|
status: record.status,
|
|
1113
1135
|
detail: record.detail,
|
|
1114
1136
|
needsApproval: record.needsApproval,
|
|
1137
|
+
staleOnRestart: record.staleOnRestart,
|
|
1115
1138
|
spec: record.spec,
|
|
1116
1139
|
startedAt: record.startedAt,
|
|
1117
1140
|
finishedAt: record.finishedAt,
|
|
@@ -1455,14 +1478,28 @@ function runInstallInner({ profile, spec, allowBuildScripts, approvedProof, pref
|
|
|
1455
1478
|
try {
|
|
1456
1479
|
existingMarker = readValidatedPendingSnapshot(profileDir);
|
|
1457
1480
|
} catch (error) {
|
|
1458
|
-
return failedNow(`profile
|
|
1481
|
+
return failedNow(`profile 里有一个读不出来的安装记录,无法判断它是什么,因此拒绝安装 ${spec}(${error.message})。请先运行 \`dsh-plugin-guard guard recover\` 处理它。`);
|
|
1459
1482
|
}
|
|
1460
1483
|
if (existingMarker !== undefined) {
|
|
1461
1484
|
const previous = existingMarker.metadata?.spec ?? existingMarker.metadata?.packageName ?? "unknown";
|
|
1462
1485
|
if (existingMarker.operation !== "install" || existingMarker.metadata?.spec !== spec) {
|
|
1463
|
-
|
|
1486
|
+
// 拒绝是对的(marker 是一次性事务,不能被覆盖),但用户看不见 marker,
|
|
1487
|
+
// 所以要说清楚挡路的是什么、以及怎么让它让开。暂停在批准闸的那种最
|
|
1488
|
+
// 常见——它正是用户刚点过取消的那次安装。
|
|
1489
|
+
const paused = pendingApprovalPaused(existingMarker) !== undefined;
|
|
1490
|
+
const what = existingMarker.operation === "remove" ? "卸载" : "安装";
|
|
1491
|
+
return failedNow(paused
|
|
1492
|
+
? `${previous} 的${what}还没做完——它停在「允许安装依赖」那一步等你决定,没有批准就不会真正装上。现在无法安装 ${spec}。重启 dsh 会撤回那次未批准的${what},之后就能重新操作;也可以运行 \`dsh-plugin-guard guard recover\` 立即撤回。`
|
|
1493
|
+
: `${previous} 的${what}还没了结,现在无法安装 ${spec}。重启 dsh 会自动了结它(装好的提交、没批准的撤回),也可以运行 \`dsh-plugin-guard guard recover\` 手动处理。`,
|
|
1494
|
+
{ staleOnRestart: true });
|
|
1464
1495
|
}
|
|
1465
1496
|
push(`[dsh-plugin-mall] resuming the paused install transaction for ${spec} — its original snapshot stays the rollback target\n`);
|
|
1497
|
+
// 事务复活:清掉暂停标记,否则重试成功后的启动提交会被它拦下错误回滚。
|
|
1498
|
+
try {
|
|
1499
|
+
clearPendingApprovalPause(profileDir);
|
|
1500
|
+
} catch (pauseError) {
|
|
1501
|
+
push(`[dsh-plugin-mall] WARNING: could not clear the approval-pause mark: ${pauseError.message}\n`);
|
|
1502
|
+
}
|
|
1466
1503
|
} else {
|
|
1467
1504
|
let snapshot;
|
|
1468
1505
|
try {
|
|
@@ -1719,7 +1756,15 @@ function runInstallInner({ profile, spec, allowBuildScripts, approvedProof, pref
|
|
|
1719
1756
|
// The retry's rebuild branch also needs this tree in place. Leave the
|
|
1720
1757
|
// on-disk state and the marker for the token retry; an abandoned pause
|
|
1721
1758
|
// is settled by startup recovery / `guard recover`, whose rollback
|
|
1722
|
-
// target is still the pre-first-attempt snapshot.
|
|
1759
|
+
// target is still the pre-first-attempt snapshot. The pause is also
|
|
1760
|
+
// marked ON the marker: without the mark a restart that passes the
|
|
1761
|
+
// static validation would commit the never-approved version and drop
|
|
1762
|
+
// the snapshot (both recovery commit points check it).
|
|
1763
|
+
try {
|
|
1764
|
+
markPendingApprovalPause(profileDir);
|
|
1765
|
+
} catch (pauseError) {
|
|
1766
|
+
push(`[dsh-plugin-mall] WARNING: could not mark the pause on the pending marker: ${pauseError.message}\n`);
|
|
1767
|
+
}
|
|
1723
1768
|
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");
|
|
1724
1769
|
} else {
|
|
1725
1770
|
try {
|
|
@@ -1747,10 +1792,21 @@ function runInstallInner({ profile, spec, allowBuildScripts, approvedProof, pref
|
|
|
1747
1792
|
// ── the background uninstall job ────────────────────────────────────────────
|
|
1748
1793
|
|
|
1749
1794
|
/** A terminal producer for fast-fail cases (no pnpm spawn needed). */
|
|
1750
|
-
|
|
1795
|
+
/**
|
|
1796
|
+
* @param staleOnRestart - true when this failure's CAUSE cannot outlive a
|
|
1797
|
+
* restart, so the browser should drop the record instead of keeping it as
|
|
1798
|
+
* history. Only the "another transaction owns this profile" refusals qualify:
|
|
1799
|
+
* startup recovery settles that transaction on the way up, so the message
|
|
1800
|
+
* ("X is still waiting at the approval gate, so Y cannot install") describes a
|
|
1801
|
+
* situation that is guaranteed gone — and it is written in the present tense,
|
|
1802
|
+
* so a reader after the restart takes it for the current state. Ordinary
|
|
1803
|
+
* failures (network, preflight blockers, pnpm errors) may well still apply
|
|
1804
|
+
* after a restart and keep their diagnostic value, so they stay.
|
|
1805
|
+
*/
|
|
1806
|
+
function failedNow(detail, { staleOnRestart = false } = {}) {
|
|
1751
1807
|
return {
|
|
1752
1808
|
cancel: () => {},
|
|
1753
|
-
done: Promise.resolve({ status: "failed", detail }),
|
|
1809
|
+
done: Promise.resolve({ status: "failed", detail, staleOnRestart }),
|
|
1754
1810
|
readOutput: () => "",
|
|
1755
1811
|
};
|
|
1756
1812
|
}
|
|
@@ -1786,7 +1842,11 @@ function runRemoveInner({ profile, packageName, _profileDir, _spawn }, selfHeale
|
|
|
1786
1842
|
// hard as a valid one, and is left untouched for the recovery path.
|
|
1787
1843
|
const markerPath = pendingMarkerPath(profileDir);
|
|
1788
1844
|
if (existsSync(markerPath)) {
|
|
1789
|
-
|
|
1845
|
+
// 存在性判断(同 markPendingSnapshot):坏 marker 也照样挡路。所以这里
|
|
1846
|
+
// 只能拿到「有」而拿不到「是什么」,文案相应保持笼统,但仍要给出路。
|
|
1847
|
+
return failedNow(
|
|
1848
|
+
`profile "${profile}" 里有一个还没了结的安装事务,在它了结之前无法卸载 ${packageName}。重启 dsh 会自动了结它(装好的提交、没批准的撤回),也可以运行 \`dsh-plugin-guard guard recover\` 手动处理(事务记录:${markerPath})。`,
|
|
1849
|
+
{ staleOnRestart: true });
|
|
1790
1850
|
}
|
|
1791
1851
|
const manifestPath = join(profileDir, "package.json");
|
|
1792
1852
|
if (!existsSync(manifestPath)) {
|
|
@@ -2175,13 +2235,50 @@ async function runTransactionFixtures() {
|
|
|
2175
2235
|
&& /paused for build-script approval/.test(output),
|
|
2176
2236
|
`status=${outcome.status} calls=${calls.length} marker=${existsSync(markerBefore)}`,
|
|
2177
2237
|
);
|
|
2238
|
+
check(
|
|
2239
|
+
"暂停必须落盘到 marker(metadata.paused)——重启后的恢复靠它区分「装完待验证」与「停在批准闸被放弃」",
|
|
2240
|
+
(() => {
|
|
2241
|
+
try {
|
|
2242
|
+
const marker = JSON.parse(readFileSync(markerBefore, "utf8"));
|
|
2243
|
+
return marker?.metadata?.paused?.reason === "paused for build-script approval";
|
|
2244
|
+
} catch { return false; }
|
|
2245
|
+
})(),
|
|
2246
|
+
);
|
|
2247
|
+
|
|
2248
|
+
// 1a-ter. 暂停期间装别的包:拒绝是对的(marker 是一次性事务),但用户
|
|
2249
|
+
// 看不见 marker,所以报错必须点名挡路的是那次「停在允许安装依赖」的
|
|
2250
|
+
// 安装,并给出让它让开的办法。这里 marker 仍带 paused。
|
|
2251
|
+
{
|
|
2252
|
+
const during = scriptedSpawn([{ code: 0, out: "Done\n" }]);
|
|
2253
|
+
const duringOutcome = await runInstall({
|
|
2254
|
+
profile: "p",
|
|
2255
|
+
spec: "other-during-pause",
|
|
2256
|
+
preflight: preflightStub("other-during-pause"),
|
|
2257
|
+
_profileDir: profileDir,
|
|
2258
|
+
_spawn: during.spawnFn,
|
|
2259
|
+
_describe: async () => [],
|
|
2260
|
+
}).done;
|
|
2261
|
+
check(
|
|
2262
|
+
// staleOnRestart:这条报错是现在时写的,而挡路的事务必然被启动恢复
|
|
2263
|
+
// 了结——留到重启之后会被当成当前状态读,所以面板要撤掉它。
|
|
2264
|
+
"暂停期间装别的包 → 拒绝,报错点名「停在允许安装依赖」+ 撤回办法 + 标记重启后失效",
|
|
2265
|
+
duringOutcome.status === "failed"
|
|
2266
|
+
&& /停在「允许安装依赖」/.test(duringOutcome.detail ?? "")
|
|
2267
|
+
&& /重启 dsh/.test(duringOutcome.detail ?? "")
|
|
2268
|
+
&& duringOutcome.staleOnRestart === true
|
|
2269
|
+
&& during.calls.length === 0,
|
|
2270
|
+
`status=${duringOutcome.status} detail=${(duringOutcome.detail ?? "").slice(0, 120)}`,
|
|
2271
|
+
);
|
|
2272
|
+
}
|
|
2178
2273
|
|
|
2179
2274
|
// 1a-bis. 同 spec 重试接管暂停的 marker:不再新建快照(回滚目标仍是
|
|
2180
|
-
// 第一次安装前的现场),继续 spawn pnpm
|
|
2275
|
+
// 第一次安装前的现场),继续 spawn pnpm;这次给批准后的成功路径——
|
|
2276
|
+
// completed 后 marker 保留给启动提交,且暂停标记必须已被接管清掉
|
|
2277
|
+
// (否则启动提交会被它拦下错误回滚)。异 spec 则拒绝且不 spawn。
|
|
2181
2278
|
{
|
|
2182
2279
|
const snapshotRootDir = join(dirname(dirname(profileDir)), "guard", "snapshots");
|
|
2183
2280
|
const snapshotsBefore = readdirSync(snapshotRootDir, { withFileTypes: true }).filter((e) => e.isDirectory()).length;
|
|
2184
|
-
const retry = scriptedSpawn([{ code: 0, out: "
|
|
2281
|
+
const retry = scriptedSpawn([{ code: 0, out: "Done in 1s\n" }]);
|
|
2185
2282
|
const retryProducer = runInstall({
|
|
2186
2283
|
profile: "p",
|
|
2187
2284
|
spec: "some-plugin",
|
|
@@ -2194,12 +2291,18 @@ async function runTransactionFixtures() {
|
|
|
2194
2291
|
const retryOutput = retryProducer.readOutput();
|
|
2195
2292
|
const snapshotsAfter = readdirSync(snapshotRootDir, { withFileTypes: true }).filter((e) => e.isDirectory()).length;
|
|
2196
2293
|
check(
|
|
2197
|
-
"同 spec 重试接管暂停的 marker →
|
|
2198
|
-
retryOutcome.status === "
|
|
2199
|
-
&& Array.isArray(retryOutcome.needsApproval)
|
|
2294
|
+
"同 spec 重试接管暂停的 marker → 复用原快照、继续安装并清掉暂停标记",
|
|
2295
|
+
retryOutcome.status === "completed"
|
|
2200
2296
|
&& retry.calls.length === 1
|
|
2201
2297
|
&& /resuming the paused install transaction/.test(retryOutput)
|
|
2202
|
-
&& snapshotsAfter === snapshotsBefore
|
|
2298
|
+
&& snapshotsAfter === snapshotsBefore
|
|
2299
|
+
&& existsSync(markerBefore)
|
|
2300
|
+
&& (() => {
|
|
2301
|
+
try {
|
|
2302
|
+
const marker = JSON.parse(readFileSync(markerBefore, "utf8"));
|
|
2303
|
+
return marker?.metadata?.paused === undefined;
|
|
2304
|
+
} catch { return false; }
|
|
2305
|
+
})(),
|
|
2203
2306
|
`status=${retryOutcome.status} calls=${retry.calls.length} snapshots=${snapshotsBefore}->${snapshotsAfter}`,
|
|
2204
2307
|
);
|
|
2205
2308
|
const other = scriptedSpawn([{ code: 0, out: "Done\n" }]);
|
|
@@ -2212,9 +2315,13 @@ async function runTransactionFixtures() {
|
|
|
2212
2315
|
_describe: async () => [],
|
|
2213
2316
|
}).done;
|
|
2214
2317
|
check(
|
|
2215
|
-
|
|
2318
|
+
// 此刻 paused 已被上面的接管清掉,所以报错走的是「未了结」那一支,
|
|
2319
|
+
// 而不是「停在允许安装依赖」那一支。
|
|
2320
|
+
"异 spec 遇既有(已非暂停)marker → 拒绝且不 spawn",
|
|
2216
2321
|
otherOutcome.status === "failed"
|
|
2217
|
-
&&
|
|
2322
|
+
&& /还没了结/.test(otherOutcome.detail ?? "")
|
|
2323
|
+
&& !/停在「允许安装依赖」/.test(otherOutcome.detail ?? "")
|
|
2324
|
+
&& otherOutcome.staleOnRestart === true
|
|
2218
2325
|
&& other.calls.length === 0,
|
|
2219
2326
|
`status=${otherOutcome.status} calls=${other.calls.length}`,
|
|
2220
2327
|
);
|
|
@@ -2551,6 +2658,8 @@ async function runTransactionFixtures() {
|
|
|
2551
2658
|
&& installOutcome.status === "failed"
|
|
2552
2659
|
&& removeOutcome.status === "failed"
|
|
2553
2660
|
&& /not a dependency/.test(removeOutcome.detail ?? "")
|
|
2661
|
+
// 普通失败不标记:重启治不好「这个包本来就不在依赖里」。
|
|
2662
|
+
&& removeOutcome.staleOnRestart !== true
|
|
2554
2663
|
&& procs.length === 1,
|
|
2555
2664
|
`procs=${procs.length} install=${installOutcome.status} remove=${removeOutcome.status} ${JSON.stringify(removeOutcome.detail)}`,
|
|
2556
2665
|
);
|
|
@@ -2579,9 +2688,12 @@ async function runTransactionFixtures() {
|
|
|
2579
2688
|
const snapshotsDir = join(dirname(marker), "snapshots");
|
|
2580
2689
|
const leftoverSnapshots = existsSync(snapshotsDir) ? readdirSync(snapshotsDir) : [];
|
|
2581
2690
|
check(
|
|
2582
|
-
|
|
2691
|
+
// 不标 staleOnRestart:损坏的 marker 重启后仍然 fail-closed 留给人工
|
|
2692
|
+
// 检查,不会被启动恢复了结——这条失败的原因活得过重启,面板要留着。
|
|
2693
|
+
"损坏/既有 pending marker → install 不 spawn、不覆盖证据、不遗留新 snapshot,且不标记重启后失效",
|
|
2583
2694
|
outcome.status === "failed"
|
|
2584
|
-
&&
|
|
2695
|
+
&& /读不出来的安装记录/.test(outcome.detail ?? "")
|
|
2696
|
+
&& outcome.staleOnRestart !== true
|
|
2585
2697
|
&& calls.length === 0
|
|
2586
2698
|
&& readFileSync(marker, "utf8") === corruptBytes
|
|
2587
2699
|
&& leftoverSnapshots.length === 0,
|
|
@@ -2604,7 +2716,8 @@ async function runTransactionFixtures() {
|
|
|
2604
2716
|
check(
|
|
2605
2717
|
"pending marker 存在 → remove 拒绝执行且不 spawn pnpm,marker 保留",
|
|
2606
2718
|
outcome.status === "failed"
|
|
2607
|
-
&&
|
|
2719
|
+
&& /还没了结的安装事务/.test(outcome.detail ?? "")
|
|
2720
|
+
&& outcome.staleOnRestart === true
|
|
2608
2721
|
&& procs.length === 0
|
|
2609
2722
|
&& existsSync(pendingMarkerPath(profileDir)),
|
|
2610
2723
|
`status=${outcome.status} procs=${procs.length} ${JSON.stringify(outcome.detail)}`,
|