@1e0zj/dsh-plugin-mall 0.3.1 → 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 +120 -26
- package/src/guard.js +629 -7
- package/src/index.js +124 -4
- package/src/installer.js +218 -22
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)。读法照抄
|
|
@@ -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;
|
|
@@ -822,6 +829,9 @@ export function createJobTracker({ producerFactory } = {}) {
|
|
|
822
829
|
record.status = status;
|
|
823
830
|
record.detail = outcome?.detail;
|
|
824
831
|
record.needsApproval = outcome?.needsApproval;
|
|
832
|
+
// 原因活不过一次重启的失败(被别的未了结事务挡住):浏览器据此在
|
|
833
|
+
// 重启后撤掉记录,而不是把一段现在时的描述留在面板上当现状读。
|
|
834
|
+
record.staleOnRestart = outcome?.staleOnRestart === true;
|
|
825
835
|
record.finishedAt = Date.now();
|
|
826
836
|
|
|
827
837
|
if (status === "completed") {
|
|
@@ -918,6 +928,13 @@ export function createJobTracker({ producerFactory } = {}) {
|
|
|
918
928
|
const record = records.get(String(jobId));
|
|
919
929
|
if (record === undefined) throw new Error(`unknown install job ${JSON.stringify(String(jobId))}`);
|
|
920
930
|
const isSameSession = record.surface !== "browser" || (record.session !== "" && record.session === session);
|
|
931
|
+
const delta = typeof record.readOutput === "function"
|
|
932
|
+
? record.readOutput()
|
|
933
|
+
: typeof record.producer?.readOutput === "function" ? record.producer.readOutput() : "";
|
|
934
|
+
// Accumulate the drained deltas so `list` can restore the full log after a
|
|
935
|
+
// remount: the polling client drains destructively, so without this the
|
|
936
|
+
// backend would hold no history at all.
|
|
937
|
+
record.log = String(record.log ?? "") + delta;
|
|
921
938
|
return {
|
|
922
939
|
snapshot: {
|
|
923
940
|
id: record.id,
|
|
@@ -926,6 +943,7 @@ export function createJobTracker({ producerFactory } = {}) {
|
|
|
926
943
|
status: record.status,
|
|
927
944
|
detail: record.detail,
|
|
928
945
|
needsApproval: record.needsApproval,
|
|
946
|
+
staleOnRestart: record.staleOnRestart,
|
|
929
947
|
approvalToken: isSameSession ? record.approvalToken : undefined,
|
|
930
948
|
// extras(如预检结论)同样只对同一 session 可见,与 approvalToken 同规格。
|
|
931
949
|
extras: isSameSession ? record.extras : undefined,
|
|
@@ -933,12 +951,49 @@ export function createJobTracker({ producerFactory } = {}) {
|
|
|
933
951
|
startedAt: record.startedAt,
|
|
934
952
|
finishedAt: record.finishedAt,
|
|
935
953
|
},
|
|
936
|
-
output:
|
|
937
|
-
? record.readOutput()
|
|
938
|
-
: typeof record.producer?.readOutput === "function" ? record.producer.readOutput() : "",
|
|
954
|
+
output: delta,
|
|
939
955
|
};
|
|
940
956
|
},
|
|
941
957
|
|
|
958
|
+
/**
|
|
959
|
+
* Every live record as {id, snapshot, output}, oldest first — for a freshly
|
|
960
|
+
* mounted client to restore its task panel. The install of a plugin whose
|
|
961
|
+
* bundle patch rewrites cordis.patch.yml replays the assembly tree and
|
|
962
|
+
* remounts this very UI mid-flight, dropping every React state; the backend
|
|
963
|
+
* records survive (1h/20-entry prune), so the remounted panel can show the
|
|
964
|
+
* finished task, its log, and the restart button instead of going blank
|
|
965
|
+
* with no signal at all (real incident: an update finished, the panel
|
|
966
|
+
* vanished, and the user learned it worked only by checking versions after
|
|
967
|
+
* a manual restart). Session visibility mirrors get(); dismissed records
|
|
968
|
+
* are skipped — "清空" must survive a remount too.
|
|
969
|
+
*/
|
|
970
|
+
list(session) {
|
|
971
|
+
const out = [];
|
|
972
|
+
for (const record of records.values()) {
|
|
973
|
+
if (record.dismissed === true) continue;
|
|
974
|
+
const isSameSession = record.surface !== "browser" || (record.session !== "" && record.session === session);
|
|
975
|
+
out.push({
|
|
976
|
+
id: record.id,
|
|
977
|
+
snapshot: {
|
|
978
|
+
id: record.id,
|
|
979
|
+
kind: record.kind,
|
|
980
|
+
label: record.label,
|
|
981
|
+
status: record.status,
|
|
982
|
+
detail: record.detail,
|
|
983
|
+
needsApproval: record.needsApproval,
|
|
984
|
+
staleOnRestart: record.staleOnRestart,
|
|
985
|
+
approvalToken: isSameSession ? record.approvalToken : undefined,
|
|
986
|
+
extras: isSameSession ? record.extras : undefined,
|
|
987
|
+
spec: record.spec,
|
|
988
|
+
startedAt: record.startedAt,
|
|
989
|
+
finishedAt: record.finishedAt,
|
|
990
|
+
},
|
|
991
|
+
output: String(record.log ?? ""),
|
|
992
|
+
});
|
|
993
|
+
}
|
|
994
|
+
return out;
|
|
995
|
+
},
|
|
996
|
+
|
|
942
997
|
cancel(jobId, session) {
|
|
943
998
|
const record = records.get(String(jobId));
|
|
944
999
|
if (record === undefined) throw new Error(`unknown install job ${JSON.stringify(String(jobId))}`);
|
|
@@ -969,6 +1024,9 @@ export function createJobTracker({ producerFactory } = {}) {
|
|
|
969
1024
|
if (record.surface === "browser" && (record.session === "" || record.session !== session)) {
|
|
970
1025
|
return false;
|
|
971
1026
|
}
|
|
1027
|
+
// Marked, not deleted: `list` (panel restore after a remount) skips these,
|
|
1028
|
+
// so a cleared panel stays cleared across remounts.
|
|
1029
|
+
record.dismissed = true;
|
|
972
1030
|
if (record.approvalToken) {
|
|
973
1031
|
invalidateApprovalToken(record.approvalToken, record.session || undefined, record.surface);
|
|
974
1032
|
record.approvalToken = undefined;
|
|
@@ -1482,6 +1540,17 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
|
|
|
1482
1540
|
return rpcFail(error);
|
|
1483
1541
|
}
|
|
1484
1542
|
}
|
|
1543
|
+
case "jobs": {
|
|
1544
|
+
// Panel restore for a freshly mounted client: an install that rewrites
|
|
1545
|
+
// cordis.patch.yml replays the assembly tree and remounts this UI,
|
|
1546
|
+
// dropping all React state. The task records live here.
|
|
1547
|
+
try {
|
|
1548
|
+
const session = requireBrowserSession(payload?.session);
|
|
1549
|
+
return rpcOk({ jobs: tracker.list(session), hostStartedAt: pluginLoadedAt });
|
|
1550
|
+
} catch (error) {
|
|
1551
|
+
return rpcFail(error);
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1485
1554
|
case "restart": {
|
|
1486
1555
|
const profile = String(payload?.profile ?? defaultProfile).trim();
|
|
1487
1556
|
try {
|
|
@@ -1572,6 +1641,11 @@ export function apply(ctx, config = {}) {
|
|
|
1572
1641
|
console.log(`[dsh-plugin-mall] startup recovery: committed the pending install for profile "${defaultProfile}"`);
|
|
1573
1642
|
} else if (result.action === "rolled-back") {
|
|
1574
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}`);
|
|
1575
1649
|
}
|
|
1576
1650
|
} catch (error) {
|
|
1577
1651
|
// 恢复失败绝不能拖垮插件加载:报出来,让市场照常可用(用户还能手动
|
|
@@ -2291,6 +2365,52 @@ export async function runSelfTests() {
|
|
|
2291
2365
|
const snapAfterDismiss = sessionTracker.get(sessionJobId, "session-alpha").snapshot;
|
|
2292
2366
|
check("dismiss 后 job snapshot 中 approvalToken 为 undefined", snapAfterDismiss.approvalToken === undefined);
|
|
2293
2367
|
|
|
2368
|
+
// ── 5b. list(重挂载恢复):日志累积、dismissed 过滤、session 隔离 ────────
|
|
2369
|
+
// 安装事务改写 cordis.patch.yml 会让 dsh 重放装配树、市场 UI 整体重挂载,
|
|
2370
|
+
// 前端 state 全丢。任务记录在后端活着——list 就是恢复通道:drain 过的
|
|
2371
|
+
// 日志增量必须已在后端累积成全量,「清空」过的条目不得被拉回。
|
|
2372
|
+
{
|
|
2373
|
+
const pendingLines = ["line-a\n", "line-b\n"];
|
|
2374
|
+
const lineProducer = {
|
|
2375
|
+
cancel: () => {},
|
|
2376
|
+
done: Promise.resolve({ status: "completed", detail: "done" }),
|
|
2377
|
+
readOutput: () => pendingLines.shift() ?? "",
|
|
2378
|
+
};
|
|
2379
|
+
const listTracker = createJobTracker({ producerFactory: () => lineProducer });
|
|
2380
|
+
const listJobId = listTracker.start({
|
|
2381
|
+
profile: "web",
|
|
2382
|
+
spec: "log-pkg",
|
|
2383
|
+
profileDir,
|
|
2384
|
+
surface: "browser",
|
|
2385
|
+
session: "session-alpha",
|
|
2386
|
+
});
|
|
2387
|
+
await new Promise((resolvePromise) => setImmediate(resolvePromise));
|
|
2388
|
+
const drain1 = listTracker.get(listJobId, "session-alpha").output;
|
|
2389
|
+
const drain2 = listTracker.get(listJobId, "session-alpha").output;
|
|
2390
|
+
const restoredJob = listTracker.list("session-alpha").find((entry) => entry.id === listJobId);
|
|
2391
|
+
check(
|
|
2392
|
+
"list 恢复全量日志(drain 过的增量在后端累积成完整历史)",
|
|
2393
|
+
drain1 === "line-a\n" && drain2 === "line-b\n"
|
|
2394
|
+
&& restoredJob !== undefined
|
|
2395
|
+
&& restoredJob.output === "line-a\nline-b\n"
|
|
2396
|
+
&& restoredJob.snapshot.status === "completed"
|
|
2397
|
+
&& restoredJob.snapshot.spec === "log-pkg",
|
|
2398
|
+
JSON.stringify({ drain1, drain2, restored: restoredJob?.output }),
|
|
2399
|
+
);
|
|
2400
|
+
const crossSessionJob = listTracker.list("session-beta").find((entry) => entry.id === listJobId);
|
|
2401
|
+
check(
|
|
2402
|
+
"list 对异 session 不暴露 approvalToken/extras(与 get 同规格)",
|
|
2403
|
+
crossSessionJob !== undefined
|
|
2404
|
+
&& crossSessionJob.snapshot.approvalToken === undefined
|
|
2405
|
+
&& crossSessionJob.snapshot.extras === undefined,
|
|
2406
|
+
);
|
|
2407
|
+
listTracker.dismiss(listJobId, "session-alpha");
|
|
2408
|
+
check(
|
|
2409
|
+
"dismiss 后 list 不再返回该条(「清空」跨重挂载存活)",
|
|
2410
|
+
listTracker.list("session-alpha").every((entry) => entry.id !== listJobId),
|
|
2411
|
+
);
|
|
2412
|
+
}
|
|
2413
|
+
|
|
2294
2414
|
// ── 6. Windows profile names & restart plan ──────────────────────────────
|
|
2295
2415
|
check("合法 profile 名称识别", isSafeProfileName("web", true) && isSafeProfileName("profile_1", true) && isSafeProfileName("dev-test", true));
|
|
2296
2416
|
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 { 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,
|
|
@@ -1444,17 +1467,52 @@ function runInstallInner({ profile, spec, allowBuildScripts, approvedProof, pref
|
|
|
1444
1467
|
// first live pnpm add runs. These are the files that decide what pnpm
|
|
1445
1468
|
// installs and what dsh loads; the marker is what lets startup/CLI recovery
|
|
1446
1469
|
// roll the profile back if the plugin proves unloadable.
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1470
|
+
//
|
|
1471
|
+
// An existing marker means one of two things. A needsApproval pause from a
|
|
1472
|
+
// previous attempt of THIS SAME install resumes: its snapshot keeps the
|
|
1473
|
+
// rollback target at "before this install first began", and re-snapshotting
|
|
1474
|
+
// now would capture the paused half-installed state as the rollback target.
|
|
1475
|
+
// Anything else (different spec, remove transaction, corrupt marker) is
|
|
1476
|
+
// refused — the recovery path owns it.
|
|
1477
|
+
let existingMarker;
|
|
1453
1478
|
try {
|
|
1454
|
-
|
|
1479
|
+
existingMarker = readValidatedPendingSnapshot(profileDir);
|
|
1455
1480
|
} catch (error) {
|
|
1456
|
-
|
|
1457
|
-
|
|
1481
|
+
return failedNow(`profile 里有一个读不出来的安装记录,无法判断它是什么,因此拒绝安装 ${spec}(${error.message})。请先运行 \`dsh-plugin-guard guard recover\` 处理它。`);
|
|
1482
|
+
}
|
|
1483
|
+
if (existingMarker !== undefined) {
|
|
1484
|
+
const previous = existingMarker.metadata?.spec ?? existingMarker.metadata?.packageName ?? "unknown";
|
|
1485
|
+
if (existingMarker.operation !== "install" || existingMarker.metadata?.spec !== spec) {
|
|
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 });
|
|
1495
|
+
}
|
|
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
|
+
}
|
|
1503
|
+
} else {
|
|
1504
|
+
let snapshot;
|
|
1505
|
+
try {
|
|
1506
|
+
snapshot = createProfileSnapshot(profileDir, { spec });
|
|
1507
|
+
} catch (error) {
|
|
1508
|
+
return failedNow(`cannot snapshot profile before installing ${spec}: ${error.message} — refusing to touch the profile`);
|
|
1509
|
+
}
|
|
1510
|
+
try {
|
|
1511
|
+
markPendingSnapshot(snapshot, { spec, preflight });
|
|
1512
|
+
} catch (error) {
|
|
1513
|
+
rmSync(snapshot.dir, { recursive: true, force: true });
|
|
1514
|
+
return failedNow(`cannot register the install pending marker for ${spec}: ${error.message} — refusing to touch the profile`);
|
|
1515
|
+
}
|
|
1458
1516
|
}
|
|
1459
1517
|
|
|
1460
1518
|
// Neutralize existing allowBuilds before every first pnpm add so strict-dep-builds
|
|
@@ -1687,6 +1745,27 @@ function runInstallInner({ profile, spec, allowBuildScripts, approvedProof, pref
|
|
|
1687
1745
|
}
|
|
1688
1746
|
if (result.status === "completed") {
|
|
1689
1747
|
// Keep the marker as-is
|
|
1748
|
+
} else if (Array.isArray(result.needsApproval) && result.needsApproval.length > 0) {
|
|
1749
|
+
// needsApproval is a PAUSE awaiting the user's decision, not a terminal
|
|
1750
|
+
// failure — do not roll back. Rolling back would tear out the candidate
|
|
1751
|
+
// pnpm just installed and rewrite the manifest to the old version, so
|
|
1752
|
+
// the retry's profile fingerprint and preflight report drift with the
|
|
1753
|
+
// changed on-disk state and the approval token's digest check can never
|
|
1754
|
+
// pass (real incident: dsh-better-sidebar 0.12.3 → 0.13.0 update died
|
|
1755
|
+
// exactly here, "invalid approval token: preflight report changed").
|
|
1756
|
+
// The retry's rebuild branch also needs this tree in place. Leave the
|
|
1757
|
+
// on-disk state and the marker for the token retry; an abandoned pause
|
|
1758
|
+
// is settled by startup recovery / `guard recover`, whose rollback
|
|
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
|
+
}
|
|
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");
|
|
1690
1769
|
} else {
|
|
1691
1770
|
try {
|
|
1692
1771
|
rollbackPendingSnapshot(profileDir);
|
|
@@ -1713,10 +1792,21 @@ function runInstallInner({ profile, spec, allowBuildScripts, approvedProof, pref
|
|
|
1713
1792
|
// ── the background uninstall job ────────────────────────────────────────────
|
|
1714
1793
|
|
|
1715
1794
|
/** A terminal producer for fast-fail cases (no pnpm spawn needed). */
|
|
1716
|
-
|
|
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 } = {}) {
|
|
1717
1807
|
return {
|
|
1718
1808
|
cancel: () => {},
|
|
1719
|
-
done: Promise.resolve({ status: "failed", detail }),
|
|
1809
|
+
done: Promise.resolve({ status: "failed", detail, staleOnRestart }),
|
|
1720
1810
|
readOutput: () => "",
|
|
1721
1811
|
};
|
|
1722
1812
|
}
|
|
@@ -1752,7 +1842,11 @@ function runRemoveInner({ profile, packageName, _profileDir, _spawn }, selfHeale
|
|
|
1752
1842
|
// hard as a valid one, and is left untouched for the recovery path.
|
|
1753
1843
|
const markerPath = pendingMarkerPath(profileDir);
|
|
1754
1844
|
if (existsSync(markerPath)) {
|
|
1755
|
-
|
|
1845
|
+
// 存在性判断(同 markPendingSnapshot):坏 marker 也照样挡路。所以这里
|
|
1846
|
+
// 只能拿到「有」而拿不到「是什么」,文案相应保持笼统,但仍要给出路。
|
|
1847
|
+
return failedNow(
|
|
1848
|
+
`profile "${profile}" 里有一个还没了结的安装事务,在它了结之前无法卸载 ${packageName}。重启 dsh 会自动了结它(装好的提交、没批准的撤回),也可以运行 \`dsh-plugin-guard guard recover\` 手动处理(事务记录:${markerPath})。`,
|
|
1849
|
+
{ staleOnRestart: true });
|
|
1756
1850
|
}
|
|
1757
1851
|
const manifestPath = join(profileDir, "package.json");
|
|
1758
1852
|
if (!existsSync(manifestPath)) {
|
|
@@ -2093,7 +2187,9 @@ async function runTransactionFixtures() {
|
|
|
2093
2187
|
}
|
|
2094
2188
|
|
|
2095
2189
|
// 1a. exit 0 + "Ignored build scripts" 且未批准:必须停在批准闸(failed +
|
|
2096
|
-
// needsApproval),携带 proof,绝不 finalize
|
|
2190
|
+
// needsApproval),携带 proof,绝不 finalize,且是暂停不是失败——现场与
|
|
2191
|
+
// marker 原样保留给带 token 的重试(回滚会让重试的 approval token 必死,
|
|
2192
|
+
// 见收尾处的真实事故注释),只 spawn 一次。
|
|
2097
2193
|
{
|
|
2098
2194
|
const { profileDir, cleanup } = makeTempProfile("ignored-gate");
|
|
2099
2195
|
try {
|
|
@@ -2118,8 +2214,10 @@ async function runTransactionFixtures() {
|
|
|
2118
2214
|
}],
|
|
2119
2215
|
});
|
|
2120
2216
|
const outcome = await producer.done;
|
|
2217
|
+
const output = producer.readOutput();
|
|
2218
|
+
const markerBefore = pendingMarkerPath(profileDir);
|
|
2121
2219
|
check(
|
|
2122
|
-
"退出码 0 + Ignored build scripts(未批准)→ 停在批准闸,返回 proof,不 finalize",
|
|
2220
|
+
"退出码 0 + Ignored build scripts(未批准)→ 停在批准闸,返回 proof,不 finalize,暂停保留 marker",
|
|
2123
2221
|
outcome.status === "failed"
|
|
2124
2222
|
&& Array.isArray(outcome.needsApproval)
|
|
2125
2223
|
&& outcome.needsApproval.some((entry) => entry.name === "node-pty")
|
|
@@ -2133,9 +2231,101 @@ async function runTransactionFixtures() {
|
|
|
2133
2231
|
&& entry.contentHash !== "0".repeat(64)
|
|
2134
2232
|
&& entry.weeklyDownloads === 123)
|
|
2135
2233
|
&& calls.length === 1
|
|
2136
|
-
&&
|
|
2137
|
-
|
|
2234
|
+
&& existsSync(markerBefore)
|
|
2235
|
+
&& /paused for build-script approval/.test(output),
|
|
2236
|
+
`status=${outcome.status} calls=${calls.length} marker=${existsSync(markerBefore)}`,
|
|
2138
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
|
+
}
|
|
2273
|
+
|
|
2274
|
+
// 1a-bis. 同 spec 重试接管暂停的 marker:不再新建快照(回滚目标仍是
|
|
2275
|
+
// 第一次安装前的现场),继续 spawn pnpm;这次给批准后的成功路径——
|
|
2276
|
+
// completed 后 marker 保留给启动提交,且暂停标记必须已被接管清掉
|
|
2277
|
+
// (否则启动提交会被它拦下错误回滚)。异 spec 则拒绝且不 spawn。
|
|
2278
|
+
{
|
|
2279
|
+
const snapshotRootDir = join(dirname(dirname(profileDir)), "guard", "snapshots");
|
|
2280
|
+
const snapshotsBefore = readdirSync(snapshotRootDir, { withFileTypes: true }).filter((e) => e.isDirectory()).length;
|
|
2281
|
+
const retry = scriptedSpawn([{ code: 0, out: "Done in 1s\n" }]);
|
|
2282
|
+
const retryProducer = runInstall({
|
|
2283
|
+
profile: "p",
|
|
2284
|
+
spec: "some-plugin",
|
|
2285
|
+
preflight: preflightStub("some-plugin"),
|
|
2286
|
+
_profileDir: profileDir,
|
|
2287
|
+
_spawn: retry.spawnFn,
|
|
2288
|
+
_describe: async () => [],
|
|
2289
|
+
});
|
|
2290
|
+
const retryOutcome = await retryProducer.done;
|
|
2291
|
+
const retryOutput = retryProducer.readOutput();
|
|
2292
|
+
const snapshotsAfter = readdirSync(snapshotRootDir, { withFileTypes: true }).filter((e) => e.isDirectory()).length;
|
|
2293
|
+
check(
|
|
2294
|
+
"同 spec 重试接管暂停的 marker → 复用原快照、继续安装并清掉暂停标记",
|
|
2295
|
+
retryOutcome.status === "completed"
|
|
2296
|
+
&& retry.calls.length === 1
|
|
2297
|
+
&& /resuming the paused install transaction/.test(retryOutput)
|
|
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
|
+
})(),
|
|
2306
|
+
`status=${retryOutcome.status} calls=${retry.calls.length} snapshots=${snapshotsBefore}->${snapshotsAfter}`,
|
|
2307
|
+
);
|
|
2308
|
+
const other = scriptedSpawn([{ code: 0, out: "Done\n" }]);
|
|
2309
|
+
const otherOutcome = await runInstall({
|
|
2310
|
+
profile: "p",
|
|
2311
|
+
spec: "another-plugin",
|
|
2312
|
+
preflight: preflightStub("another-plugin"),
|
|
2313
|
+
_profileDir: profileDir,
|
|
2314
|
+
_spawn: other.spawnFn,
|
|
2315
|
+
_describe: async () => [],
|
|
2316
|
+
}).done;
|
|
2317
|
+
check(
|
|
2318
|
+
// 此刻 paused 已被上面的接管清掉,所以报错走的是「未了结」那一支,
|
|
2319
|
+
// 而不是「停在允许安装依赖」那一支。
|
|
2320
|
+
"异 spec 遇既有(已非暂停)marker → 拒绝且不 spawn",
|
|
2321
|
+
otherOutcome.status === "failed"
|
|
2322
|
+
&& /还没了结/.test(otherOutcome.detail ?? "")
|
|
2323
|
+
&& !/停在「允许安装依赖」/.test(otherOutcome.detail ?? "")
|
|
2324
|
+
&& otherOutcome.staleOnRestart === true
|
|
2325
|
+
&& other.calls.length === 0,
|
|
2326
|
+
`status=${otherOutcome.status} calls=${other.calls.length}`,
|
|
2327
|
+
);
|
|
2328
|
+
}
|
|
2139
2329
|
const call = calls[0] ?? { args: [], options: {} };
|
|
2140
2330
|
check(
|
|
2141
2331
|
"实装 argv/env:strict-dep-builds + peer 关闭 + cwd/shell 正确",
|
|
@@ -2468,6 +2658,8 @@ async function runTransactionFixtures() {
|
|
|
2468
2658
|
&& installOutcome.status === "failed"
|
|
2469
2659
|
&& removeOutcome.status === "failed"
|
|
2470
2660
|
&& /not a dependency/.test(removeOutcome.detail ?? "")
|
|
2661
|
+
// 普通失败不标记:重启治不好「这个包本来就不在依赖里」。
|
|
2662
|
+
&& removeOutcome.staleOnRestart !== true
|
|
2471
2663
|
&& procs.length === 1,
|
|
2472
2664
|
`procs=${procs.length} install=${installOutcome.status} remove=${removeOutcome.status} ${JSON.stringify(removeOutcome.detail)}`,
|
|
2473
2665
|
);
|
|
@@ -2496,9 +2688,12 @@ async function runTransactionFixtures() {
|
|
|
2496
2688
|
const snapshotsDir = join(dirname(marker), "snapshots");
|
|
2497
2689
|
const leftoverSnapshots = existsSync(snapshotsDir) ? readdirSync(snapshotsDir) : [];
|
|
2498
2690
|
check(
|
|
2499
|
-
|
|
2691
|
+
// 不标 staleOnRestart:损坏的 marker 重启后仍然 fail-closed 留给人工
|
|
2692
|
+
// 检查,不会被启动恢复了结——这条失败的原因活得过重启,面板要留着。
|
|
2693
|
+
"损坏/既有 pending marker → install 不 spawn、不覆盖证据、不遗留新 snapshot,且不标记重启后失效",
|
|
2500
2694
|
outcome.status === "failed"
|
|
2501
|
-
&&
|
|
2695
|
+
&& /读不出来的安装记录/.test(outcome.detail ?? "")
|
|
2696
|
+
&& outcome.staleOnRestart !== true
|
|
2502
2697
|
&& calls.length === 0
|
|
2503
2698
|
&& readFileSync(marker, "utf8") === corruptBytes
|
|
2504
2699
|
&& leftoverSnapshots.length === 0,
|
|
@@ -2521,7 +2716,8 @@ async function runTransactionFixtures() {
|
|
|
2521
2716
|
check(
|
|
2522
2717
|
"pending marker 存在 → remove 拒绝执行且不 spawn pnpm,marker 保留",
|
|
2523
2718
|
outcome.status === "failed"
|
|
2524
|
-
&&
|
|
2719
|
+
&& /还没了结的安装事务/.test(outcome.detail ?? "")
|
|
2720
|
+
&& outcome.staleOnRestart === true
|
|
2525
2721
|
&& procs.length === 0
|
|
2526
2722
|
&& existsSync(pendingMarkerPath(profileDir)),
|
|
2527
2723
|
`status=${outcome.status} procs=${procs.length} ${JSON.stringify(outcome.detail)}`,
|