@noob-stupid/dsh-plugin-console 0.5.14 → 0.5.15
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/lib/client.js +95 -8
- package/lib/server/domain/dep-source.js +4 -0
- package/lib/server/domain/framework.js +89 -2
- package/lib/server/domain/install-diagnose.js +76 -6
- package/lib/server/domain/lockfile-health.js +433 -0
- package/lib/server/domain/repoland.js +141 -46
- package/lib/server/domain/sources.js +29 -1
- package/lib/server/infra/fsx.js +50 -1
- package/lib/server/routes/framework-upgrade.js +2 -1
- package/lib/server/routes/framework.js +26 -2
- package/lib/server/routes/index.js +4 -0
- package/lib/server/routes/lockfile.js +54 -0
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -99,6 +99,17 @@ window.__ModuleLoader__.load({
|
|
|
99
99
|
safetyBackfillNone: "已装插件都在清单里声明过了(官方面板的「已安装」与 pnpm 都能看见)",
|
|
100
100
|
safetyBackfillBtn: "补声明({n} 项)",
|
|
101
101
|
safetyBackfillDone: "补声明完成",
|
|
102
|
+
// 2026-09-27 加法:依赖锁体检 / 重建(只读体检 + 用户显式触发;中英各一份)
|
|
103
|
+
safetyLockTitle: "依赖锁体检",
|
|
104
|
+
safetyLockDesc: "只读体检:声明依赖能否在 registry 解析 + pnpm-lock.yaml 是否与清单一致(不会自动改你的 profile)",
|
|
105
|
+
safetyLockOk: "依赖锁与清单一致,无需处理",
|
|
106
|
+
safetyLockBad: "发现 {n} 类问题",
|
|
107
|
+
safetyLockBlocked: "有依赖在 registry 上解析不到:{pkgs}(重建会停下,不会静默丢弃依赖)",
|
|
108
|
+
safetyLockRepaired: "lock 已重建并与清单对齐",
|
|
109
|
+
safetyLockRepairPartial: "重建后仍有差距,见下方问题",
|
|
110
|
+
safetyLockNoRebuild: "当前不需要重建",
|
|
111
|
+
safetyLockCheckBtn: "体检",
|
|
112
|
+
safetyLockRepairBtn: "重建 lock(显式)",
|
|
102
113
|
safetyMode: "运行模式",
|
|
103
114
|
safetyModeManaged: "托管:框架升级与回滚由本控制台负责(默认,行为与之前一致)",
|
|
104
115
|
safetyModeManagedOn: "已切到托管模式:框架升级与回滚仍由本控制台负责",
|
|
@@ -255,6 +266,10 @@ window.__ModuleLoader__.load({
|
|
|
255
266
|
diagKindMinimumReleaseAge: "版本太新被安全间隔拦下:稍后再试",
|
|
256
267
|
diagKindMissingTool: "本机缺少命令(corepack/pnpm/git):装好再重试",
|
|
257
268
|
diagKindNotFound: "registry 上没有这个包或版本:核对包名或换源",
|
|
269
|
+
// 2026-09-27 加法:三个新分类(fetch-404 / lockfile-outdated / supply-chain-age)的短句
|
|
270
|
+
diagKindFetch404: "依赖在 registry 上 404:核对包名/版本或换源(不会丢弃依赖)",
|
|
271
|
+
diagKindLockfileOutdated: "lock 与清单对不上:可在「升级安全」里显式体检并重建",
|
|
272
|
+
diagKindSupplyChainAge: "版本太新被供应链闸拦下:稍后再试(不自动绕过)",
|
|
258
273
|
diagKindLocked: "文件被占用或无权限:关掉占用进程后重试",
|
|
259
274
|
diagKindOther: "未归类的安装失败:先原样重试,仍失败看原始输出",
|
|
260
275
|
diagRetried: "网络超时:已自动重试一次(更长超时)",
|
|
@@ -594,6 +609,17 @@ window.__ModuleLoader__.load({
|
|
|
594
609
|
safetyBackfillNone: "Every installed plugin is declared in the manifest (visible to plugin pages and pnpm)",
|
|
595
610
|
safetyBackfillBtn: "Declare {n}",
|
|
596
611
|
safetyBackfillDone: "Declarations written",
|
|
612
|
+
// 2026-09-27 additive: dependency lock health / rebuild (read-only check + explicit user trigger)
|
|
613
|
+
safetyLockTitle: "Dependency lock health",
|
|
614
|
+
safetyLockDesc: "Read-only check: whether declared deps resolve on the registry and whether pnpm-lock.yaml matches the manifest (your profile is never auto-edited)",
|
|
615
|
+
safetyLockOk: "Lock file matches the manifest — nothing to do",
|
|
616
|
+
safetyLockBad: "{n} kind(s) of problem found",
|
|
617
|
+
safetyLockBlocked: "Deps not resolvable on the registry: {pkgs} (the rebuild stops; your deps are never silently dropped)",
|
|
618
|
+
safetyLockRepaired: "Lock rebuilt and aligned with the manifest",
|
|
619
|
+
safetyLockRepairPartial: "Still out of sync after the rebuild — see the problems below",
|
|
620
|
+
safetyLockNoRebuild: "No rebuild needed right now",
|
|
621
|
+
safetyLockCheckBtn: "Check",
|
|
622
|
+
safetyLockRepairBtn: "Rebuild lock (explicit)",
|
|
597
623
|
safetyMode: "Operating mode",
|
|
598
624
|
safetyModeManaged: "Managed: this console owns framework upgrade and rollback (default, unchanged behaviour)",
|
|
599
625
|
safetyModeManagedOn: "Switched to managed: framework upgrade and rollback stay with this console",
|
|
@@ -750,6 +776,10 @@ window.__ModuleLoader__.load({
|
|
|
750
776
|
diagKindMinimumReleaseAge: "Version too new for the release-age gate: retry later",
|
|
751
777
|
diagKindMissingTool: "Missing local command (corepack/pnpm/git): install it, then retry",
|
|
752
778
|
diagKindNotFound: "Package or version not on the registry: check the name or switch source",
|
|
779
|
+
// 2026-09-27 additive: short lines for the three new kinds (fetch-404 / lockfile-outdated / supply-chain-age)
|
|
780
|
+
diagKindFetch404: "Dependency 404 on the registry: check name/version or switch source (your deps are never dropped)",
|
|
781
|
+
diagKindLockfileOutdated: "Lock file disagrees with the manifest: run the explicit check/rebuild in Upgrade safety",
|
|
782
|
+
diagKindSupplyChainAge: "Too new for the release-age gate: retry later (never auto-bypassed)",
|
|
753
783
|
diagKindLocked: "File in use or no permission: close the holder, then retry",
|
|
754
784
|
diagKindOther: "Unclassified failure: retry as-is; if it fails again read the raw output",
|
|
755
785
|
diagRetried: "Network timeout: auto-retried once (longer timeout)",
|
|
@@ -1128,6 +1158,16 @@ window.__ModuleLoader__.load({
|
|
|
1128
1158
|
}
|
|
1129
1159
|
return data;
|
|
1130
1160
|
}
|
|
1161
|
+
/** 重启服务(按钮 / 工具项 / 自更新后共用):服务端在「本实例由桌面端外壳托管」等场景会**拒绝**
|
|
1162
|
+
* 并回结构化短句 —— 必须把它显示出来,否则按钮看起来是「点了没反应」(旧实现把错误吞掉了)。 */
|
|
1163
|
+
function restartServiceViaServer(setMessage, options) {
|
|
1164
|
+
const reload = (options ?? {}).reload !== false;
|
|
1165
|
+
if (reload) setMessage(t("restarting"));
|
|
1166
|
+
call("/plugin-console/restart", {}).then(
|
|
1167
|
+
() => { if (reload) window.setTimeout(() => window.location.reload(), 12000); },
|
|
1168
|
+
(error) => { setMessage(error !== null && typeof error.message === "string" && error.message !== "" ? error.message : t("failed")); },
|
|
1169
|
+
);
|
|
1170
|
+
}
|
|
1131
1171
|
/** 超时类错误附上可操作提示(网络黑洞期的最常见表现)。 */
|
|
1132
1172
|
function friendlyGithubError(error) {
|
|
1133
1173
|
const msg = error !== null && typeof error.message === "string" ? error.message : String(error);
|
|
@@ -2882,8 +2922,11 @@ window.__ModuleLoader__.load({
|
|
|
2882
2922
|
: diagnosis.kind === "minimumReleaseAge" ? t("diagKindMinimumReleaseAge")
|
|
2883
2923
|
: diagnosis.kind === "missing-tool" ? t("diagKindMissingTool")
|
|
2884
2924
|
: diagnosis.kind === "not-found" ? t("diagKindNotFound")
|
|
2885
|
-
: diagnosis.kind === "
|
|
2886
|
-
: t("
|
|
2925
|
+
: diagnosis.kind === "fetch-404" ? t("diagKindFetch404")
|
|
2926
|
+
: diagnosis.kind === "lockfile-outdated" ? t("diagKindLockfileOutdated")
|
|
2927
|
+
: diagnosis.kind === "supply-chain-age" ? t("diagKindSupplyChainAge")
|
|
2928
|
+
: diagnosis.kind === "locked" ? t("diagKindLocked")
|
|
2929
|
+
: t("diagKindOther");
|
|
2887
2930
|
lines.push({
|
|
2888
2931
|
key: "diagnosis",
|
|
2889
2932
|
text: view.status === "failed"
|
|
@@ -4034,7 +4077,7 @@ window.__ModuleLoader__.load({
|
|
|
4034
4077
|
el("button", { type: "button", className: styles.toggle, onClick: fwStatusRefresh }, t("fwPanelRefreshStatus")),
|
|
4035
4078
|
el("button", { type: "button", className: styles.toggle, disabled: fwCheckBusy, onClick: () => fwCheckRefresh(true) }, fwCheckBusy ? t("fwPanelChecking") : t("fwPanelRecheck"))),
|
|
4036
4079
|
el("div", { className: styles.rowTop },
|
|
4037
|
-
el("button", { type: "button", className: styles.toggle, onClick: () => { setMessage
|
|
4080
|
+
el("button", { type: "button", className: styles.toggle, onClick: () => { restartServiceViaServer(setMessage); } }, t("restartService")))))
|
|
4038
4081
|
: null,
|
|
4039
4082
|
gateOpen
|
|
4040
4083
|
? el("div", { className: styles.modalBackdrop, onClick: (event) => { if (event.target === event.currentTarget) setGateOpen(false); } },
|
|
@@ -4255,7 +4298,7 @@ window.__ModuleLoader__.load({
|
|
|
4255
4298
|
}, t("frameworkRollbackBtn") + " → " + (rollbackInfo.from ?? "?")))
|
|
4256
4299
|
: null)
|
|
4257
4300
|
: null,
|
|
4258
|
-
message !== null ? el("div", { className: styles.messageRow }, el("p", { className: styles.message }, message), reloadHint ? el("button", { type: "button", className: styles.toggle, onClick: () => window.location.reload() }, t("reloadPage")) : null, restartHint ? el("button", { type: "button", className: styles.toggle, onClick: () => { setMessage
|
|
4301
|
+
message !== null ? el("div", { className: styles.messageRow }, el("p", { className: styles.message }, message), reloadHint ? el("button", { type: "button", className: styles.toggle, onClick: () => window.location.reload() }, t("reloadPage")) : null, restartHint ? el("button", { type: "button", className: styles.toggle, onClick: () => { restartServiceViaServer(setMessage); } }, t("restartService")) : null) : null,
|
|
4259
4302
|
// 适配门「软禁」的风险确认(用户定案:自动禁用,但允许手动强行启用)
|
|
4260
4303
|
riskyConfirm !== null
|
|
4261
4304
|
? el("div", { className: styles.detail, "data-risky": "true" },
|
|
@@ -4300,7 +4343,7 @@ const lockWarn = data.lockUpdated === false && typeof data.lockNote === "string"
|
|
|
4300
4343
|
? "⚠️ " + data.lockNote
|
|
4301
4344
|
: "";
|
|
4302
4345
|
setMessage((data.note ?? "已更新") + (lockWarn === "" ? "" : "。" + lockWarn) + ",正在重启生效…");
|
|
4303
|
-
|
|
4346
|
+
restartServiceViaServer(setMessage, { reload: false });
|
|
4304
4347
|
} else {
|
|
4305
4348
|
setMessage(t("failed") + ":" + (data?.reason ?? "未知"));
|
|
4306
4349
|
}
|
|
@@ -4459,7 +4502,7 @@ setMessage(t("failed") + ":" + (data?.reason ?? "未知"));
|
|
|
4459
4502
|
type: "button",
|
|
4460
4503
|
className: styles.toolItem,
|
|
4461
4504
|
title: t("restartService"),
|
|
4462
|
-
onClick: () => { setMessage
|
|
4505
|
+
onClick: () => { restartServiceViaServer(setMessage); },
|
|
4463
4506
|
}, t("restartService")),
|
|
4464
4507
|
el("button", {
|
|
4465
4508
|
type: "button",
|
|
@@ -5054,9 +5097,38 @@ setMessage(t("failed") + ":" + (data?.reason ?? "未知"));
|
|
|
5054
5097
|
const [note, setNote] = react.useState(null);
|
|
5055
5098
|
const [section, setSection] = react.useState(null);
|
|
5056
5099
|
const [backfill, setBackfill] = react.useState(null);
|
|
5100
|
+
// 2026-09-27 加法:依赖锁体检结果(null = 还没体检过;不自动跑,用户点「体检」才打接口)
|
|
5101
|
+
const [lockFix, setLockFix] = react.useState(null);
|
|
5057
5102
|
const load = async () => {
|
|
5058
5103
|
try { const data = await call("/plugin-console/compat-status"); if (data !== null) setStatus(data); } catch {}
|
|
5059
5104
|
};
|
|
5105
|
+
// 依赖锁体检:**只读**接口,不打任何写操作
|
|
5106
|
+
const lockCheck = async () => {
|
|
5107
|
+
if (busy) return;
|
|
5108
|
+
setBusy(true);
|
|
5109
|
+
try {
|
|
5110
|
+
const data = await call("/plugin-console/lockfile-check", {});
|
|
5111
|
+
setLockFix(data ?? null);
|
|
5112
|
+
setNote({ text: data?.hint ?? t("safetySwitchFailed") });
|
|
5113
|
+
} catch (error) { setNote({ text: (error && error.message) || t("safetySwitchFailed") }); }
|
|
5114
|
+
finally { setBusy(false); }
|
|
5115
|
+
};
|
|
5116
|
+
// 重建 lock:**必须由用户点**(服务端也只认显式 POST);失败如实说,不假装成功
|
|
5117
|
+
const lockRepair = async () => {
|
|
5118
|
+
if (busy) return;
|
|
5119
|
+
setBusy(true);
|
|
5120
|
+
try {
|
|
5121
|
+
const done = await call("/plugin-console/lockfile-repair", {});
|
|
5122
|
+
const text = done?.action === "repaired" ? t("safetyLockRepaired")
|
|
5123
|
+
: done?.action === "blocked" ? fill(t("safetyLockBlocked"), [["pkgs", (done.packages ?? []).join("、") || "—"]])
|
|
5124
|
+
: done?.action === "noop" ? t("safetyLockNoRebuild")
|
|
5125
|
+
: t("safetyLockRepairPartial");
|
|
5126
|
+
setNote({ text: text + (typeof done?.hint === "string" ? t("diagColon") + done.hint : "") });
|
|
5127
|
+
const again = await call("/plugin-console/lockfile-check", {}).catch(() => null);
|
|
5128
|
+
if (again !== null && again !== undefined) setLockFix(again);
|
|
5129
|
+
} catch (error) { setNote({ text: (error && error.message) || t("safetySwitchFailed") }); }
|
|
5130
|
+
finally { setBusy(false); }
|
|
5131
|
+
};
|
|
5060
5132
|
const loadBackfill = async () => {
|
|
5061
5133
|
try { const data = await call("/plugin-console/declare-installed", { mode: "plan" }); if (data !== null) setBackfill(data); } catch {}
|
|
5062
5134
|
};
|
|
@@ -5119,11 +5191,11 @@ setMessage(t("failed") + ":" + (data?.reason ?? "未知"));
|
|
|
5119
5191
|
pulse: "M3 12h4l2-5 4 10 2-5h6"
|
|
5120
5192
|
};
|
|
5121
5193
|
const icon = (key) => el("svg", stroke, el("path", { d: ICONS[key] || ICONS.shield }));
|
|
5122
|
-
const row = (key, title, desc, control) => el("div", { style: { display: "flex", alignItems: "flex-start", gap: "10px", padding: "10px 12px" } },
|
|
5194
|
+
const row = (key, title, desc, control, descTitle) => el("div", { style: { display: "flex", alignItems: "flex-start", gap: "10px", padding: "10px 12px" } },
|
|
5123
5195
|
icon(key),
|
|
5124
5196
|
el("div", { style: { flex: "1 1 auto", minWidth: "0" } },
|
|
5125
5197
|
el("div", { style: { fontSize: "13px", fontWeight: 500 } }, title),
|
|
5126
|
-
el("div", { style: { fontSize: "12px", opacity: .7, marginTop: "2px", wordBreak: "break-word" } }, desc)),
|
|
5198
|
+
el("div", { style: { fontSize: "12px", opacity: .7, marginTop: "2px", wordBreak: "break-word" }, title: descTitle === undefined ? undefined : descTitle }, desc)),
|
|
5127
5199
|
control === undefined || control === null ? null : control);
|
|
5128
5200
|
const sw = (on, onClick, title) => el("button", { type: "button", title: title, onClick: onClick, disabled: busy, style: { width: "40px", height: "22px", borderRadius: "999px", border: "none", padding: 0, cursor: busy ? "default" : "pointer", background: on ? "var(--dsw-alias-bg-accent, #4d6bfe)" : "var(--dsw-alias-bg-secondary, rgba(127,127,127,.35))", position: "relative", flex: "0 0 auto", marginTop: "2px" } },
|
|
5129
5201
|
el("span", { style: { position: "absolute", top: "3px", left: on ? "21px" : "3px", width: "16px", height: "16px", borderRadius: "50%", background: "#fff", transition: "left .18s cubic-bezier(.34,1.56,.64,1)" } }));
|
|
@@ -5151,6 +5223,21 @@ setMessage(t("failed") + ":" + (data?.reason ?? "未知"));
|
|
|
5151
5223
|
row("back", t("safetyRollbackTitle"), rb.applicable === true ? fill(t("safetyRollbackOk"), [["from", rb.from], ["to", rb.to]]) : fill(t("safetyRollbackNone"), [["reason", rb.reason === null || rb.reason === undefined ? "-" : rb.reason]])),
|
|
5152
5224
|
row("scan", t("safetyFpTitle"), fp.changed === true ? fill(t("safetyFpChanged"), [["reasons", (fp.reasons || []).join(";")]]) : t("safetyFpStable"), fp.changed === true ? el("button", { type: "button", className: styles.toggle, onClick: ack, disabled: busy }, t("safetyFpAck")) : null),
|
|
5153
5225
|
row("list", t("safetyBackfillTitle"), pending > 0 ? fill(t("safetyBackfillDesc"), [["n", pending]]) : t("safetyBackfillNone"), pending > 0 ? el("button", { type: "button", className: styles.toggle, disabled: busy, onClick: applyBackfill }, fill(t("safetyBackfillBtn"), [["n", pending]])) : null),
|
|
5226
|
+
// 依赖锁体检 / 重建(2026-09-27 加法):体检只读;重建必须用户点(服务端也只认显式 POST)。
|
|
5227
|
+
// 面板只放短句,服务端的长 hint 挂 title 悬浮提示(与安装诊断同一约定)。
|
|
5228
|
+
row("scan", t("safetyLockTitle"),
|
|
5229
|
+
lockFix === null ? t("safetyLockDesc")
|
|
5230
|
+
: lockFix.ok === true ? t("safetyLockOk")
|
|
5231
|
+
: fill(t("safetyLockBad"), [["n", (lockFix.problems ?? []).length]])
|
|
5232
|
+
+ ((lockFix.packages404 ?? []).length === 0 ? "" : " · " + lockFix.packages404.join("、")),
|
|
5233
|
+
el("div", { style: { display: "flex", gap: "8px", flex: "0 0 auto" } },
|
|
5234
|
+
el("button", { type: "button", className: styles.toggle, disabled: busy, title: t("safetyLockDesc"), onClick: lockCheck }, t("safetyLockCheckBtn")),
|
|
5235
|
+
lockFix !== null && lockFix.repair?.applicable === true
|
|
5236
|
+
? el("button", { type: "button", className: styles.toggle, disabled: busy, title: lockFix.repair?.command ?? "", onClick: lockRepair }, t("safetyLockRepairBtn"))
|
|
5237
|
+
: null),
|
|
5238
|
+
lockFix !== null && lockFix.ok !== true && Array.isArray(lockFix.problems) && lockFix.problems.length > 0
|
|
5239
|
+
? lockFix.problems.map((p) => p.hint).join("\n")
|
|
5240
|
+
: undefined),
|
|
5154
5241
|
row("pulse", t("safetyAdaptTitle"), t("safetyAdaptDesc"), el("div", { style: { display: "flex", gap: "8px", flex: "0 0 auto" } },
|
|
5155
5242
|
el("button", { type: "button", className: styles.toggle, disabled: busy, onClick: () => preflight(false) }, t("fwPreflightBtn")),
|
|
5156
5243
|
el("button", { type: "button", className: styles.toggle, disabled: busy, onClick: () => preflight(true) }, t("fwPreflightPatch")))),
|
|
@@ -33,11 +33,14 @@ const PLUGIN_SRC_DIR = 'plugin-src'
|
|
|
33
33
|
* 404 / 网络失败都归为「不可解析」——调用方据此决定**绝不写裸版本号**。
|
|
34
34
|
* `version` 为 null 时只判包是否存在;`hasVersion` 表示指定版本是否在 versions 里
|
|
35
35
|
* (release 通道装的版本可能比 registry 上的 latest 还新,只判包名存在是不够的)。
|
|
36
|
+
* 2026-09-27 加法:`includeMeta: true` 时把 registry 原始元数据一起返回(`meta`),
|
|
37
|
+
* 供「依赖锁体检」读发布时间(供应链间隔判定)用;默认不返回,既有调用方形状不变。
|
|
36
38
|
*/
|
|
37
39
|
async function probeRegistryPackage(packageName, registries = [], options = {}) {
|
|
38
40
|
const fetchJson = typeof options.fetchJson === 'function' ? options.fetchJson : fetchJsonUrl
|
|
39
41
|
const version = typeof options.version === 'string' && options.version !== '' ? options.version : null
|
|
40
42
|
const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : 8000
|
|
43
|
+
const withMeta = options.includeMeta === true
|
|
41
44
|
const list = (Array.isArray(registries) ? registries : []).filter((r) => typeof r === 'string' && r.trim() !== '')
|
|
42
45
|
const tries = list.length > 0 ? list : ['https://registry.npmmirror.com']
|
|
43
46
|
const encoded = packageName.startsWith('@')
|
|
@@ -60,6 +63,7 @@ async function probeRegistryPackage(packageName, registries = [], options = {})
|
|
|
60
63
|
latest,
|
|
61
64
|
registry: base,
|
|
62
65
|
tried,
|
|
66
|
+
...(withMeta ? { meta } : {}),
|
|
63
67
|
}
|
|
64
68
|
} catch (error) {
|
|
65
69
|
tried.push(`${base}:${error instanceof Error ? error.message : String(error)}`)
|
|
@@ -136,6 +136,82 @@ function checkpointFrameworkTree(fwRoot, destRoot) {
|
|
|
136
136
|
return { dest, mirrored }
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
/**
|
|
140
|
+
* 宿主形态识别(2026-09-26 真机事故后的**加法分支**)。
|
|
141
|
+
*
|
|
142
|
+
* 事故现场(官方桌面端实例,127.0.0.1:19387,20:28):
|
|
143
|
+
* 桌面端里 DSH host 是 **Electron 二进制**跑的 asar 内入口
|
|
144
|
+
* (`D:\dsh-desktop\DeepSeek Harness.exe --expose-internals …\app.asar\dsh\node_modules\@deepseek-ai\dsh-desktop-host\lib\index.js …`),
|
|
145
|
+
* 不是 `node bin.js web`。我们的「重启服务 / 回滚 / 框架升级重启」那一套会
|
|
146
|
+
* `Stop-Process` 掉 host,然后 `Resolve-DshBin` 三种方式全找不到 bin.js
|
|
147
|
+
* (桌面端 profile 的 node_modules 里根本没有 `@deepseek-ai` 作用域)→ 拉不起来 →
|
|
148
|
+
* Electron 外壳记 `crash-…-host.log: dsh desktop host exited with 4294967295`。
|
|
149
|
+
*
|
|
150
|
+
* 判据(**只看「我们自己是不是被 Electron 运行时承载」**,任一命中即判定为外壳托管):
|
|
151
|
+
* ① `process.versions.electron` 非空 —— 当前进程就跑在 Electron 运行时里(含 ELECTRON_RUN_AS_NODE 模式);
|
|
152
|
+
* ② 环境变量 `ELECTRON_RUN_AS_NODE` 非空 —— Electron 以 Node 模式承载 DSH host;
|
|
153
|
+
* ③ `process.execPath` 或任一 `process.argv` 命中 `resources\app.asar` —— 入口在打包 Electron 应用内;
|
|
154
|
+
* ④ `process.execPath` 文件名是 `electron(.exe)`;
|
|
155
|
+
* ⑤ `process.resourcesPath` 存在 **且** execPath 文件名不是 node —— 纯 Node 进程没有这个属性。
|
|
156
|
+
*
|
|
157
|
+
* **刻意不用「父进程是 Electron 外壳」当判据**:本机实测(2026-09-26)独立实例
|
|
158
|
+
* `node …\dsh\lib\bin.js web --no-open`(3080, pid 32464)的父进程也是 Electron
|
|
159
|
+
* (`%TEMP%\…\DeepSeek Harness.exe`,用户的启动器),拿父进程判定会把**独立 dsh web 实例误判成桌面端托管**
|
|
160
|
+
* —— 那正好违反「独立 dsh web 进程的既有行为一字不改」。父进程只作为 evidence 记录,不参与判定。
|
|
161
|
+
*/
|
|
162
|
+
const SHELL_HOSTED_NOTICE = '本实例由桌面端外壳托管,请在桌面端内重启/更新'
|
|
163
|
+
|
|
164
|
+
function detectHostShape(deps = {}) {
|
|
165
|
+
const env = deps.env ?? process.env ?? {}
|
|
166
|
+
const versions = deps.versions ?? process.versions ?? {}
|
|
167
|
+
const execPath = String(deps.execPath ?? process.execPath ?? '')
|
|
168
|
+
const argv = Array.isArray(deps.argv) ? deps.argv : (process.argv ?? [])
|
|
169
|
+
const resourcesPath = deps.resourcesPath === undefined ? process.resourcesPath : deps.resourcesPath
|
|
170
|
+
const reasons = []
|
|
171
|
+
const electronVersion = typeof versions.electron === 'string' && versions.electron !== '' ? versions.electron : ''
|
|
172
|
+
if (electronVersion !== '') reasons.push(`process.versions.electron=${electronVersion}(当前进程跑在 Electron 运行时里)`)
|
|
173
|
+
const runAsNode = typeof env.ELECTRON_RUN_AS_NODE === 'string' && env.ELECTRON_RUN_AS_NODE !== '' ? env.ELECTRON_RUN_AS_NODE : ''
|
|
174
|
+
if (runAsNode !== '') reasons.push(`环境变量 ELECTRON_RUN_AS_NODE=${runAsNode}(Electron 以 Node 模式承载 DSH host)`)
|
|
175
|
+
const argvText = argv.map((a) => String(a ?? ''))
|
|
176
|
+
const asarHit = [execPath, ...argvText].find((p) => /resources[\\/]app\.asar/iu.test(p)) ?? null
|
|
177
|
+
if (asarHit !== null) reasons.push(`入口在打包 Electron 应用内(${asarHit})`)
|
|
178
|
+
const exeBase = basename(execPath).toLowerCase()
|
|
179
|
+
if (/^electron(\.exe)?$/u.test(exeBase)) reasons.push(`process.execPath 是 Electron(${execPath})`)
|
|
180
|
+
const resourcesText = typeof resourcesPath === 'string' ? resourcesPath : ''
|
|
181
|
+
if (resourcesText !== '' && !/^node(\.exe)?$/u.test(exeBase)) {
|
|
182
|
+
reasons.push(`process.resourcesPath 存在(${resourcesText})且可执行文件不是 node(${exeBase || '未知'})`)
|
|
183
|
+
}
|
|
184
|
+
const hosted = reasons.length > 0
|
|
185
|
+
return {
|
|
186
|
+
hosted,
|
|
187
|
+
kind: hosted ? 'desktop-shell' : 'standalone',
|
|
188
|
+
reasons,
|
|
189
|
+
notice: hosted ? SHELL_HOSTED_NOTICE : null,
|
|
190
|
+
evidence: {
|
|
191
|
+
execPath,
|
|
192
|
+
argv1: String(argv[1] ?? ''),
|
|
193
|
+
asarEntry: asarHit,
|
|
194
|
+
electronVersion,
|
|
195
|
+
runAsNode,
|
|
196
|
+
resourcesPath: resourcesText === '' ? null : resourcesText,
|
|
197
|
+
ppid: typeof deps.ppid === 'number' ? deps.ppid : (typeof process.ppid === 'number' ? process.ppid : null),
|
|
198
|
+
},
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* 外壳托管时的标准拒绝载荷(**纯数据,不碰 HTTP**):路由层负责把它翻成响应。
|
|
204
|
+
* 返回 null = 独立 `dsh web` 实例,调用方走原路径(一字不改)。
|
|
205
|
+
*/
|
|
206
|
+
function shellHostedRefusal(deps) {
|
|
207
|
+
const shape = detectHostShape(deps)
|
|
208
|
+
if (shape.hosted !== true) return null
|
|
209
|
+
return {
|
|
210
|
+
error: SHELL_HOSTED_NOTICE,
|
|
211
|
+
details: { code: 'hosted-by-shell', hostKind: shape.kind, reasons: shape.reasons, evidence: shape.evidence },
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
139
215
|
/**
|
|
140
216
|
* 生成「拉起 DSH 服务」的 PowerShell 前导块(升级脚本 / 回滚脚本共用,v0.3.37 事故修复)。
|
|
141
217
|
*
|
|
@@ -154,11 +230,21 @@ function checkpointFrameworkTree(fwRoot, destRoot) {
|
|
|
154
230
|
* 目录 → 顶层可见链接 → .pnpm 里最新的一个;
|
|
155
231
|
* 3) 全路径参数一律 `Test-Path -LiteralPath`,失败只记录、不抛错。
|
|
156
232
|
*/
|
|
157
|
-
function relaunchPrelude({ nodePath, pluginDir, fwRoot, target, ps }) {
|
|
233
|
+
function relaunchPrelude({ nodePath, pluginDir, fwRoot, target, ps, hosted }) {
|
|
234
|
+
// 未显式传 hosted 时自己判定一次(自接线,避免调用方漏传导致守卫失效)。
|
|
235
|
+
// 独立 `dsh web` 实例下 detectHostShape().hosted === false ⇒ 生成结果与旧实现逐字节相同。
|
|
236
|
+
const shellHosted = typeof hosted === 'boolean' ? hosted : detectHostShape().hosted === true
|
|
158
237
|
const probe = "const path=require('path');const p=require.resolve('@deepseek-ai/dsh/package.json',{paths:[process.env.DSH_RESOLVE_ROOT]});console.log(path.join(path.dirname(p),'lib','bin.js'))"
|
|
159
238
|
const pnpmBin = (dirExpr) => `Join-Path ${dirExpr} 'node_modules\\@deepseek-ai\\dsh\\lib\\bin.js'`
|
|
239
|
+
// 外壳托管(桌面端 Electron 承载):脚本里**只留一句拒绝**,绝不 Start-Process 拉起第二个实例。
|
|
240
|
+
// `hosted === false` 时这里是空数组 —— 生成的脚本与旧实现**逐字节相同**(加法分支)。
|
|
241
|
+
const hostedFlag = shellHosted === true ? [`$script:DSH_HOSTED = $true`] : []
|
|
242
|
+
const hostedGuard = shellHosted === true
|
|
243
|
+
? [` if ($script:DSH_HOSTED) { Log ('本实例由桌面端外壳托管,跳过拉起(' + $tag + '):请在桌面端内重启/更新'); return $false }`]
|
|
244
|
+
: []
|
|
160
245
|
return [
|
|
161
246
|
`$launchLog = ''`,
|
|
247
|
+
...hostedFlag,
|
|
162
248
|
// 心跳(v0.3.39):脚本每推进一小步就更新一次心跳文件的时间戳。服务端据此区分
|
|
163
249
|
// 「脚本还在干活」与「脚本进程被系统/启动器杀掉」。2026-09-11 真机事故:回滚脚本
|
|
164
250
|
// 干完活之后被 Ctrl+C 类事件结束(计划任务 Last Result = 0xC000013A),终态没写成,
|
|
@@ -180,6 +266,7 @@ function relaunchPrelude({ nodePath, pluginDir, fwRoot, target, ps }) {
|
|
|
180
266
|
` return ''`,
|
|
181
267
|
`}`,
|
|
182
268
|
`function Invoke-DshRelaunch($tag) {`,
|
|
269
|
+
...hostedGuard,
|
|
183
270
|
` $bin = Resolve-DshBin`,
|
|
184
271
|
` if ([string]::IsNullOrWhiteSpace($bin)) { Log ('拉起失败(' + $tag + '):node resolve / .pnpm / 顶层链接 三种方式都找不到 dsh 的 bin.js,请手动启动 DSH'); return $false }`,
|
|
185
272
|
` try { Add-Content -Path $launchLog -Value ((Get-Date -Format 'yyyy-MM-dd HH:mm:ss') + ' 拉起(' + $tag + '): ' + $bin) -Encoding UTF8 } catch {}`,
|
|
@@ -390,4 +477,4 @@ async function preflightDisableIncompatible({ ports, profileDir, patchPath, targ
|
|
|
390
477
|
}
|
|
391
478
|
return { disabled, skipped }
|
|
392
479
|
}
|
|
393
|
-
export { FRAMEWORK_STATE_FILE, FRAMEWORK_BACKUP_ROOT, cleanupStaleFwTasks, resolveDshBin, locateAppBootFile, applyFrameworkTolerancePatchOnce, resolveFrameworkRootNodeModules, checkpointFrameworkTree, relaunchPrelude, isVersionAtLeast, pickFrameworkTarget, resolveFrameworkUpgradePlan, currentFrameworkVersion, backupProfileSnapshot, detectFrameworkUpgrade, preflightDisableIncompatible }
|
|
480
|
+
export { FRAMEWORK_STATE_FILE, FRAMEWORK_BACKUP_ROOT, SHELL_HOSTED_NOTICE, detectHostShape, shellHostedRefusal, cleanupStaleFwTasks, resolveDshBin, locateAppBootFile, applyFrameworkTolerancePatchOnce, resolveFrameworkRootNodeModules, checkpointFrameworkTree, relaunchPrelude, isVersionAtLeast, pickFrameworkTarget, resolveFrameworkUpgradePlan, currentFrameworkVersion, backupProfileSnapshot, detectFrameworkUpgrade, preflightDisableIncompatible }
|
|
@@ -14,6 +14,33 @@
|
|
|
14
14
|
|
|
15
15
|
/** 分类规则:**顺序敏感**(具体在前、宽泛在后;network-timeout 最宽,放最后兜住超时类文本)。 */
|
|
16
16
|
const DIAGNOSIS_RULES = [
|
|
17
|
+
{
|
|
18
|
+
// 2026-09-27 加法:pnpm 的**供应链年龄闸**(`minimumReleaseAge`)真的拦下安装时的专用错误码。
|
|
19
|
+
// 与下面泛化的 minimumReleaseAge 分开,是因为这条要带**安全边界文案**(可自行显式绕过、代价自负,
|
|
20
|
+
// 本控制台绝不代为绕过)。`withPackages` 不置位:这条错误文本里通常没有 registry URL。
|
|
21
|
+
kind: 'supply-chain-age',
|
|
22
|
+
re: /ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION|minimum release age violation/iu,
|
|
23
|
+
retry: 'later',
|
|
24
|
+
hint: '版本太新,被 pnpm 的 minimumReleaseAge(供应链安全间隔)拦下:稍后再试即可自动放行;'
|
|
25
|
+
+ '确有紧急需要时,**由你自己**显式加 `--config.minimumReleaseAge=0` 绕过(放行"刚发布不久"的版本有安全代价)——本控制台绝不替你绕过。',
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
// 2026-09-27 加法:registry 抓取 404 —— 与泛化的 not-found 分开,是因为 404 要能**点名丢的是哪个依赖**。
|
|
29
|
+
kind: 'fetch-404',
|
|
30
|
+
re: /ERR_PNPM_FETCH_404|not found in the registry|is not in the npm registry|404 Not Found[^\n]{0,80}https?:\/\/(?:registry|npm)[^\s]*/iu,
|
|
31
|
+
retry: null,
|
|
32
|
+
withPackages: true,
|
|
33
|
+
hint: '依赖在 registry 上 404(包名/版本不存在,或镜像未同步):核对包名与版本、或换一个已同步的源后重试;'
|
|
34
|
+
+ '本控制台**不会**为了让 lock 重建成功而静默丢弃你的依赖。',
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
// 2026-09-27 加法:陈旧/残缺的 pnpm-lock.yaml(pnpm 在 frozen-lockfile 下的确定性报错)。
|
|
38
|
+
// 以前落进 other,用户只能看到一句原始 stderr;现在给"这不是网络问题、是 lock 与清单对不上"的定性。
|
|
39
|
+
kind: 'lockfile-outdated',
|
|
40
|
+
re: /ERR_PNPM_OUTDATED_LOCKFILE|Cannot install with "?frozen-lockfile|lockfile is not up to date|ERR_PNPM_LOCKFILE_MISSING_DEPENDENCY/iu,
|
|
41
|
+
retry: null,
|
|
42
|
+
hint: 'pnpm-lock.yaml 与实际依赖不一致(陈旧或残缺):这不是网络问题;可在「升级安全 → 依赖锁体检」里只读体检并**显式**重建 lock(本控制台不会自动改你的 profile)。',
|
|
43
|
+
},
|
|
17
44
|
{
|
|
18
45
|
kind: 'minimumReleaseAge',
|
|
19
46
|
// pnpm 的供应链安全间隔(默认关闭,可配置):版本"太新"时拒装
|
|
@@ -39,6 +66,9 @@ const DIAGNOSIS_RULES = [
|
|
|
39
66
|
},
|
|
40
67
|
{
|
|
41
68
|
kind: 'not-found',
|
|
69
|
+
// 泛化 404:**非 registry 依赖解析**的那些(GitHub release 资源、npm 的 E404 文本等)。
|
|
70
|
+
// 上面的 fetch-404 更具体(能点名依赖),所以带 registry 特征的 404 会先被它命中;
|
|
71
|
+
// 这里的 ERR_PNPM_FETCH_404 保留为兜底(万一文本形态变化,也不至于掉进 other)。
|
|
42
72
|
re: /ERR_PNPM_FETCH_404|E404|404 Not Found|not found in the registry|is not in the npm registry/iu,
|
|
43
73
|
retry: null,
|
|
44
74
|
hint: 'registry 上没有这个包/这个版本(包名拼错、子包未发布,或镜像未同步):核对包名与版本后重试;'
|
|
@@ -61,17 +91,56 @@ const DIAGNOSIS_RULES = [
|
|
|
61
91
|
},
|
|
62
92
|
]
|
|
63
93
|
|
|
64
|
-
/**
|
|
65
|
-
*
|
|
94
|
+
/** 从失败文本里**点名**被抓取失败的依赖(纯函数,2026-09-27 加法)。
|
|
95
|
+
*
|
|
96
|
+
* 为什么必须能点名:pnpm 的 404 报错里包名只出现在 URL 路径里(`GET https://registry.npmmirror.com/dsh-github-login`),
|
|
97
|
+
* 用户在面板上只看到一句"registry 上没有这个包",还得自己去翻 stderr 才知道是哪一个。
|
|
98
|
+
* 这里把 URL 路径还原成包名(含 `@scope%2fname` 这种编码形态),去重后按出现顺序返回。
|
|
99
|
+
* 认不出来就返回空数组(调用方据此保留原样文案,绝不编造包名)。 */
|
|
100
|
+
function missingPackagesFrom(text) {
|
|
101
|
+
const raw = typeof text === 'string' ? text : String(text ?? '')
|
|
102
|
+
const found = []
|
|
103
|
+
for (const m of raw.matchAll(/https?:\/\/[^\s'"()<>]+/gu)) {
|
|
104
|
+
let path = ''
|
|
105
|
+
try {
|
|
106
|
+
path = new URL(m[0]).pathname
|
|
107
|
+
} catch {
|
|
108
|
+
continue
|
|
109
|
+
}
|
|
110
|
+
const cut = path.split('/-/')[0] // tarball 形态:/<pkg>/-/<file>.tgz → 只取包名段
|
|
111
|
+
// pnpm 的报错里 URL 常直接跟着冒号/逗号(`…/dsh-x: Not Found - 404`)→ 先去尾部标点再认包名
|
|
112
|
+
const decoded = decodeURIComponent(cut).replace(/^\/+/u, '').replace(/[/:;,.]+$/u, '')
|
|
113
|
+
if (!/^(?:@[A-Za-z0-9._-]+\/)?[A-Za-z0-9._-]+$/u.test(decoded)) continue
|
|
114
|
+
if (/\.(?:tgz|json|yaml|yml)$/iu.test(decoded)) continue
|
|
115
|
+
if (!found.includes(decoded)) found.push(decoded)
|
|
116
|
+
}
|
|
117
|
+
return found
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** 按分类取短句 hint(纯函数;2026-09-27 加法):**文案只有一份**——
|
|
121
|
+
* lockfile-health.js 的体检结论也走这里,避免"同一分类两处措辞"。认不出的分类返回 null。 */
|
|
122
|
+
function hintForKind(kind, packages = []) {
|
|
123
|
+
const rule = DIAGNOSIS_RULES.find((r) => r.kind === kind)
|
|
124
|
+
if (rule === undefined) return null
|
|
125
|
+
const list = Array.isArray(packages) ? packages.filter((p) => typeof p === 'string' && p !== '') : []
|
|
126
|
+
return rule.withPackages === true && list.length > 0 ? `${rule.hint}(涉及:${list.join('、')})` : rule.hint
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** 失败文本 → { kind, hint, retry, packages }。
|
|
130
|
+
* retry 取值:'longer-timeout'(调用方**只对它**做一次定向长超时重试)| 'later'(提示"稍后再试",不自动重试)| null。
|
|
131
|
+
* packages(2026-09-27 加法):文本里能点名的依赖包名(认不出就是空数组)——只用于展示,不参与任何判定。 */
|
|
66
132
|
function classifyInstallFailure(stderrOrMessage) {
|
|
67
133
|
const text = typeof stderrOrMessage === 'string' ? stderrOrMessage : String(stderrOrMessage ?? '')
|
|
134
|
+
const packages = missingPackagesFrom(text)
|
|
68
135
|
for (const rule of DIAGNOSIS_RULES) {
|
|
69
|
-
if (rule.re.test(text))
|
|
136
|
+
if (!rule.re.test(text)) continue
|
|
137
|
+
return { kind: rule.kind, hint: hintForKind(rule.kind, packages), retry: rule.retry, packages }
|
|
70
138
|
}
|
|
71
139
|
return {
|
|
72
140
|
kind: 'other',
|
|
73
141
|
hint: '未归类的安装失败:请把下面的原始输出用于排查;可先原样重试一次,仍失败再换源或看作业详情里的完整 stderr。',
|
|
74
142
|
retry: null,
|
|
143
|
+
packages,
|
|
75
144
|
}
|
|
76
145
|
}
|
|
77
146
|
|
|
@@ -82,11 +151,12 @@ function longerTimeoutFor(timeout, { cap = 240000 } = {}) {
|
|
|
82
151
|
return Math.min(Math.max(Math.round(base * 2), base + 15000), cap)
|
|
83
152
|
}
|
|
84
153
|
|
|
85
|
-
/**
|
|
154
|
+
/** 一行可展示文案(面板/日志用;纯函数):说清分类、是否已自动重试、点名的依赖、以及下一步建议。 */
|
|
86
155
|
function diagnosisText(diagnosis) {
|
|
87
156
|
if (diagnosis == null || typeof diagnosis !== 'object') return null
|
|
88
157
|
const retried = diagnosis.retried === true ? '(已自动重试一次:更长超时)' : ''
|
|
89
|
-
|
|
158
|
+
const pkgs = Array.isArray(diagnosis.packages) && diagnosis.packages.length > 0 ? `(涉及:${diagnosis.packages.join('、')})` : ''
|
|
159
|
+
return `失败分类:${diagnosis.kind}${retried}${diagnosis.hint ? `;${diagnosis.hint}` : ''}${pkgs}`
|
|
90
160
|
}
|
|
91
161
|
|
|
92
162
|
/** ③ 的定向重试编排(**本文件唯一的非纯函数**,只被 install-job.js 调用):
|
|
@@ -110,4 +180,4 @@ async function retryPnpmOnce({ ch, profileDir, name, registries, pnpmError, job,
|
|
|
110
180
|
}
|
|
111
181
|
}
|
|
112
182
|
|
|
113
|
-
export { classifyInstallFailure, longerTimeoutFor, diagnosisText, retryPnpmOnce, DIAGNOSIS_RULES }
|
|
183
|
+
export { classifyInstallFailure, longerTimeoutFor, diagnosisText, retryPnpmOnce, missingPackagesFrom, hintForKind, DIAGNOSIS_RULES }
|