@1e0zj/dsh-plugin-mall 0.3.2 → 0.3.4
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 +76 -13
- package/cordis.patch.yml +13 -2
- 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 +304 -31
- package/src/installer.js +133 -20
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)}`,
|