@noob-stupid/dsh-plugin-console 0.5.67 → 0.5.77
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 +55 -10
- package/lib/server/domain/dep-source.js +44 -1
- package/lib/server/domain/manifest.js +17 -3
- package/lib/server/domain/pending-restart.js +80 -0
- package/lib/server/domain/selfupdate.js +3 -3
- package/lib/server/domain/update-candidates.js +25 -5
- package/lib/server/routes/index.js +2 -0
- package/lib/server/routes/revoke-force.js +35 -0
- package/lib/server/routes/state.js +31 -3
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -280,6 +280,11 @@ window.__ModuleLoader__.load({
|
|
|
280
280
|
migrateBtn: "迁移并适配",
|
|
281
281
|
aiEmpowerFrameworkCheck: "框架适配检测",
|
|
282
282
|
userForcedTag: "补丁强制启用",
|
|
283
|
+
// ★ 0.5.76(用户 2026-10-18):禁用**出处**要写明,别只给一句"已停用"
|
|
284
|
+
disabledByBundleTag: "插件默认禁用",
|
|
285
|
+
forcedNotLoadedTag: "强制启用未生效",
|
|
286
|
+
revokeForceLabel: "撤销该强制启用",
|
|
287
|
+
revokeForceHint: "这条 disabled: false 覆盖没有让插件加载起来 —— 撤掉它,界面不再显示「强制启用」的假象(不会改动清单)",
|
|
283
288
|
protectedTag: "受保护",
|
|
284
289
|
unobserved: "未挂载",
|
|
285
290
|
pending: "等待依赖",
|
|
@@ -891,6 +896,10 @@ window.__ModuleLoader__.load({
|
|
|
891
896
|
migrateBtn: "Migrate & adapt",
|
|
892
897
|
aiEmpowerFrameworkCheck: "Framework compat check",
|
|
893
898
|
userForcedTag: "Forced on by patch",
|
|
899
|
+
disabledByBundleTag: "Disabled by plugin default",
|
|
900
|
+
forcedNotLoadedTag: "Force-enable had no effect",
|
|
901
|
+
revokeForceLabel: "Revoke this force-enable",
|
|
902
|
+
revokeForceHint: "This disabled: false override did not make the plugin load; revoking it stops the false forced-on badge (the manifest is untouched)",
|
|
894
903
|
protectedTag: "Protected",
|
|
895
904
|
unobserved: "Not mounted",
|
|
896
905
|
pending: "Waiting for dependencies",
|
|
@@ -1970,6 +1979,11 @@ function healthSignature(health) {
|
|
|
1970
1979
|
const [repoInfo, setRepoInfo] = react.useState(null);
|
|
1971
1980
|
const [subpackages, setSubpackages] = react.useState(null);
|
|
1972
1981
|
const [installing, setInstalling] = react.useState(null);
|
|
1982
|
+
// ★ 0.5.70(用户 2026-10-18):**按目标并行**的在飞集合。installing 只留作展示,
|
|
1983
|
+
// 真正的并发闸门是 inFlight —— 同一目标才拦,不同插件可同时更新。
|
|
1984
|
+
const [inFlight, setInFlight] = react.useState(() => new Set());
|
|
1985
|
+
const markInFlight = (key) => { if (typeof key === "string" && key !== "") setInFlight((prev) => { const n = new Set(prev); n.add(key); return n; }); };
|
|
1986
|
+
const clearInFlight = (key) => { if (typeof key === "string" && key !== "") setInFlight((prev) => { const n = new Set(prev); n.delete(key); return n; }); };
|
|
1973
1987
|
const [details, setDetails] = react.useState(null);
|
|
1974
1988
|
// 已安装插件"检测更新":null=未检测 / {status:'checking'} / {status:'ready', latest, error}
|
|
1975
1989
|
const [updateCheck, setUpdateCheck] = react.useState(null);
|
|
@@ -2301,7 +2315,14 @@ function healthSignature(health) {
|
|
|
2301
2315
|
const [toolbarPos, setToolbarPos] = react.useState(() => {
|
|
2302
2316
|
try {
|
|
2303
2317
|
const v = JSON.parse(localStorage.getItem("pc-toolbar-pos") || "null");
|
|
2304
|
-
if (v && Number.isFinite(v.left) && Number.isFinite(v.top))
|
|
2318
|
+
if (v && Number.isFinite(v.left) && Number.isFinite(v.top)) {
|
|
2319
|
+
// ★ 用户 2026-10-18:存下来的位置**必须钳进可视区**,否则拖到标题栏/屏幕外就再也拿不回来。
|
|
2320
|
+
// 上边界 64px 是标题栏与窗口按钮的安全区(原来只钳到 8px,正好落在标题栏里)。
|
|
2321
|
+
const SAFE_TOP = 64;
|
|
2322
|
+
const maxLeft = Math.max(8, (window.innerWidth || 1280) - 120);
|
|
2323
|
+
const maxTop = Math.max(SAFE_TOP, (window.innerHeight || 800) - 80);
|
|
2324
|
+
return { left: Math.min(Math.max(8, v.left), maxLeft), top: Math.min(Math.max(SAFE_TOP, v.top), maxTop) };
|
|
2325
|
+
}
|
|
2305
2326
|
} catch {}
|
|
2306
2327
|
return null;
|
|
2307
2328
|
});
|
|
@@ -2924,11 +2945,20 @@ function healthSignature(health) {
|
|
|
2924
2945
|
(error) => setDetails({ entryId: entry.entryId, status: "error", error }),
|
|
2925
2946
|
);
|
|
2926
2947
|
};
|
|
2948
|
+
// ★ 0.5.76:撤销"强制启用"覆盖(补丁里的 disabled: false)。只对确实 forced 的行生效,服务端会校验。
|
|
2949
|
+
const revokeForce = (entry) => {
|
|
2950
|
+
if (busy !== null) return;
|
|
2951
|
+
setBusy(entry.entryId);
|
|
2952
|
+
call("/plugin-console/revoke-force", { entryId: entry.entryId }).then(
|
|
2953
|
+
(data) => { setMessage(data?.note ?? t("revokeForceLabel")); refresh(); },
|
|
2954
|
+
(error) => { setMessage(friendlyGithubError(error).message); },
|
|
2955
|
+
).finally(() => setBusy(null));
|
|
2956
|
+
};
|
|
2927
2957
|
const checkUpdate = (entry) => {
|
|
2928
2958
|
const pkgName = entry.moduleName;
|
|
2929
2959
|
setUpdateCheck({ status: "checking" });
|
|
2930
2960
|
call("/plugin-console/check-update", { packageName: pkgName }).then(
|
|
2931
|
-
(data) => setUpdateCheck({ status: "ready", latest: data.latest ?? null, source: data.source ?? "npm", error: data.error ?? null }),
|
|
2961
|
+
(data) => setUpdateCheck({ status: "ready", latest: data.latest ?? null, next: data.next ?? null, beta: data.beta ?? null, target: data.target ?? null, source: data.source ?? "npm", error: data.error ?? null }),
|
|
2932
2962
|
(error) => setUpdateCheck({ status: "ready", latest: null, source: "npm", error: friendlyGithubError(error).message }),
|
|
2933
2963
|
);
|
|
2934
2964
|
};
|
|
@@ -3411,6 +3441,8 @@ function healthSignature(health) {
|
|
|
3411
3441
|
aiConsent(jobId, true);
|
|
3412
3442
|
}
|
|
3413
3443
|
if (data.status !== "installing") {
|
|
3444
|
+
// ★ 0.5.70:任务结束 ⇒ 释放该目标的并发锁(其它插件的更新不受影响)
|
|
3445
|
+
clearInFlight(data.packageName);
|
|
3414
3446
|
stopJobPolling(jobId);
|
|
3415
3447
|
if (data.status === "done") {
|
|
3416
3448
|
if (data.kind === "skill") {
|
|
@@ -3792,11 +3824,14 @@ function healthSignature(health) {
|
|
|
3792
3824
|
const startJob = (repo, packageName, source = "github", kind = "plugin", update = false, subdir = null) => {
|
|
3793
3825
|
// 防并发(与 addLocal 一致):已有安装/更新进行中时忽略新点击(更新按钮此前可绕过,
|
|
3794
3826
|
// 反复点击会产生几十个并发任务装同一插件)
|
|
3795
|
-
|
|
3827
|
+
// 只拦**同一个目标**:不同插件可以并行更新(服务端是作业模型,天然支持)
|
|
3828
|
+
const jobKey = repo || packageName;
|
|
3829
|
+
if (inFlight.has(jobKey)) {
|
|
3796
3830
|
setMessage(t("installBusy"));
|
|
3797
3831
|
return;
|
|
3798
3832
|
}
|
|
3799
|
-
|
|
3833
|
+
markInFlight(jobKey);
|
|
3834
|
+
setInstalling(jobKey);
|
|
3800
3835
|
setReloadHint(false);
|
|
3801
3836
|
setRestartHint(false);
|
|
3802
3837
|
setMessage(null);
|
|
@@ -3814,6 +3849,7 @@ function healthSignature(health) {
|
|
|
3814
3849
|
},
|
|
3815
3850
|
(error) => {
|
|
3816
3851
|
setMessage(t("safetySwitchFailed") + ":" + friendlyGithubError(error).message);
|
|
3852
|
+
clearInFlight(jobKey);
|
|
3817
3853
|
setInstalling(null);
|
|
3818
3854
|
},
|
|
3819
3855
|
);
|
|
@@ -4026,7 +4062,7 @@ function healthSignature(health) {
|
|
|
4026
4062
|
el("button", {
|
|
4027
4063
|
type: "button",
|
|
4028
4064
|
className: styles.toggle,
|
|
4029
|
-
disabled: (updateCheck !== null && updateCheck.status === "checking") ||
|
|
4065
|
+
disabled: (updateCheck !== null && updateCheck.status === "checking") || inFlight.has(entry.moduleName),
|
|
4030
4066
|
onClick: () => checkUpdate(entry),
|
|
4031
4067
|
}, updateCheck !== null && updateCheck.status === "checking" ? t("checkingUpdate") : t("checkUpdate")),
|
|
4032
4068
|
el("button", {
|
|
@@ -4124,6 +4160,10 @@ function healthSignature(health) {
|
|
|
4124
4160
|
entry.protected ? el("span", { className: styles.tag, "data-user": "true" }, t("protectedTag")) : null,
|
|
4125
4161
|
entry.userDisabled ? el("span", { className: styles.tag, "data-user": "true" }, t("userDisabledTag")) : null,
|
|
4126
4162
|
entry.userForced ? el("span", { className: styles.tag, "data-user": "true" }, t("userForcedTag")) : null,
|
|
4163
|
+
// ★ 0.5.76:被**别处**(插件自身补丁/框架默认)禁用的行 —— 这就是"补丁强制启用"的来由
|
|
4164
|
+
entry.disableSource === "bundle" ? el("span", { className: styles.tag, "data-user": "true", "data-source": "bundle" }, t("disabledByBundleTag")) : null,
|
|
4165
|
+
// ★ 0.5.76:补丁写了强制开启、运行时却仍没起来 ⇒ 这条覆盖是**无效的**,必须说出来
|
|
4166
|
+
entry.forcedNotLoaded === true ? el("span", { className: styles.tag, "data-user": "true", "data-source": "ineffective" }, t("forcedNotLoadedTag")) : null,
|
|
4127
4167
|
),
|
|
4128
4168
|
el("div", { className: styles.meta },
|
|
4129
4169
|
el("span", { className: styles.phase, "data-phase": entry.fiberPhase ?? "unobserved" },
|
|
@@ -4153,11 +4193,18 @@ function healthSignature(health) {
|
|
|
4153
4193
|
el("button", {
|
|
4154
4194
|
type: "button",
|
|
4155
4195
|
className: styles.toggle,
|
|
4156
|
-
disabled: !entry.toggleable || busy === entry.entryId
|
|
4196
|
+
disabled: !entry.toggleable || busy === entry.entryId, // ★ 待适配不再禁用启用(用户 2026-10-18):启用=确认启用,服务端会把它标为已采纳
|
|
4157
4197
|
// 0.5.34:结论已被当前判据撤回的行不再显示「待适配」(旧判据的误报不许当事实展示)
|
|
4158
4198
|
title: entry.pendingCompat === true ? (entry.compatRescinded === true ? t("compatRescindedHint") : t("pendingCompatHint")) : undefined,
|
|
4159
4199
|
onClick: () => toggle(entry, !entry.enabled),
|
|
4160
4200
|
}, busy === entry.entryId ? t("loadingPhase") : (entry.pendingCompat === true ? (entry.compatRescinded === true ? t("compatRescindedTag") : t("pendingCompatTag")) : t(entry.enabled ? "off" : "on"))),
|
|
4201
|
+
// ★ 0.5.76:补丁写了强制开启但运行时没起来 ⇒ 给一个撤销覆盖的入口
|
|
4202
|
+
entry.forcedNotLoaded === true ? el("button", {
|
|
4203
|
+
type: "button",
|
|
4204
|
+
className: styles.toggle,
|
|
4205
|
+
title: t("revokeForceHint"),
|
|
4206
|
+
onClick: () => revokeForce(entry),
|
|
4207
|
+
}, t("revokeForceLabel")) : null,
|
|
4161
4208
|
el("button", {
|
|
4162
4209
|
type: "button",
|
|
4163
4210
|
className: styles.toggle,
|
|
@@ -4911,9 +4958,7 @@ function healthSignature(health) {
|
|
|
4911
4958
|
el("p", { className: styles.message }, t("compatGateHint")),
|
|
4912
4959
|
el("p", { className: styles.status, "data-pending": gatePendingCount > 0 ? "true" : undefined },
|
|
4913
4960
|
gatePendingCount > 0 ? t("gatePendingInfo").replace("{n}", String(gatePendingCount)) : t("gatePendingNone")),
|
|
4914
|
-
|
|
4915
|
-
gatePendingCount > 0 ? t("gatePendingInfo").replace("{n}", String(gatePendingCount)) : t("gatePendingNone")),
|
|
4916
|
-
// 明细清单(2026-09-24 用户要求:面板要能看到是哪些插件,有列表数据)
|
|
4961
|
+
// 明细清单(2026-09-24 用户要求:面板要能看到是哪些插件,有列表数据)
|
|
4917
4962
|
gateRows !== null && gateRows.pendingCount + gateRows.adoptedCount > 0
|
|
4918
4963
|
? el("div", { style: { flexDirection: "column", gap: 6, display: "flex" } },
|
|
4919
4964
|
el("strong", { className: styles.name }, t("gateListPending").replace("{n}", String(gateRows.pendingCount))),
|
|
@@ -5328,7 +5373,7 @@ function healthSignature(health) {
|
|
|
5328
5373
|
const bw = e.currentTarget.offsetWidth || 96;
|
|
5329
5374
|
const bh = e.currentTarget.offsetHeight || 30;
|
|
5330
5375
|
const left = Math.max(8, Math.min(w - bw - 8, e.clientX - dragRef.current.grabX));
|
|
5331
|
-
const top = Math.max(
|
|
5376
|
+
const top = Math.max(64, Math.min(h - bh - 8, e.clientY - dragRef.current.grabY)); // ★ 64 = 标题栏安全区
|
|
5332
5377
|
setDragPos({ left, top });
|
|
5333
5378
|
},
|
|
5334
5379
|
onPointerUp: () => {
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
// 改用 `link:<DSH_HOME>/plugin-src/<包名>`:pnpm 的 link 协议只建符号链接,不经 registry 解析、
|
|
20
20
|
// 不经 tarball 完整性校验,lock 删掉重建、node_modules 清空重装都稳定通过。
|
|
21
21
|
|
|
22
|
-
import { existsSync, rmSync, mkdirSync, realpathSync } from 'node:fs'
|
|
22
|
+
import { existsSync, readFileSync, rmSync, mkdirSync, realpathSync } from 'node:fs'
|
|
23
23
|
import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
|
|
24
24
|
import { copyTree } from '../infra/fsx.js'
|
|
25
25
|
import { fetchJsonUrl } from '../infra/http.js'
|
|
@@ -213,7 +213,50 @@ const NPM_NAME_RE = /^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/u
|
|
|
213
213
|
* · `command` **只供展示/复制**,服务端执行时**只认 payload.action 这个白名单类型**,argv 自己拼;
|
|
214
214
|
* · payload 里只有动作类型 + 包名 + 版本 + profile 名(回显用),**没有任何命令字符串**位置。
|
|
215
215
|
*/
|
|
216
|
+
/**
|
|
217
|
+
* 读 profile 清单里某个包的**当前来源形态**(只读;读不到返回 null)。
|
|
218
|
+
* 用途:决定"缺口该怎么补" —— 路径型来源才该按 link: 钉,registry 来源该对齐 lock。
|
|
219
|
+
*/
|
|
220
|
+
function profileDepSpec(profileDir, packageName) {
|
|
221
|
+
if (typeof profileDir !== 'string' || profileDir === '') return null
|
|
222
|
+
if (typeof packageName !== 'string' || packageName === '') return null
|
|
223
|
+
try {
|
|
224
|
+
const manifest = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8'))
|
|
225
|
+
const spec = manifest?.dependencies?.[packageName]
|
|
226
|
+
return typeof spec === 'string' && spec !== '' ? spec : null
|
|
227
|
+
} catch { return null }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** 路径型来源(link:/file:)—— 只有这一族"按 link: 钉住"才说得通。 */
|
|
231
|
+
function isPathSourceSpec(spec) {
|
|
232
|
+
return typeof spec === 'string' && /^(?:link:|file:)/u.test(spec)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* **纯 registry 形态**的版本/范围(只有这一族"对齐 lock"才是正解)。
|
|
237
|
+
* 刻意收窄:git+https / https tarball / link: / file: / workspace: / portal: 一律**不算** ——
|
|
238
|
+
* 对它们推进 reconcile-lock 是错的(lock 对账解决不了来源问题),实测被 route-inventory 缺陷②抓到。
|
|
239
|
+
*/
|
|
240
|
+
function isRegistryRangeSpec(spec) {
|
|
241
|
+
if (typeof spec !== 'string') return false
|
|
242
|
+
const s = spec.trim()
|
|
243
|
+
if (s === '' || s === '*' || s === 'x') return true
|
|
244
|
+
if (/^(?:\^|~|>=|<=|>|<|=)\s*v?\d/u.test(s)) return true
|
|
245
|
+
if (/^v?\d+(?:\.\d+)?\.x$/u.test(s)) return true
|
|
246
|
+
return /^v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/u.test(s)
|
|
247
|
+
}
|
|
248
|
+
|
|
216
249
|
function suggestedPinAction({ packageName, version = null, profileDir = null, profile = null, home = undefined, reason = null } = {}) {
|
|
250
|
+
// ★ 0.5.69(用户 2026-10-18):**registry 来源的包不该被劝成 link:**
|
|
251
|
+
// 它的问题是 lock 与清单不一致,正确出路是「重建 lock」;把 registry 包改成 link: 会绕过
|
|
252
|
+
// registry 解析与 tarball 完整性校验,还会让依赖(如 zod)被当成插件钉进 plugin-src。
|
|
253
|
+
const currentSpec = profileDepSpec(profileDir, packageName)
|
|
254
|
+
if (currentSpec !== null && isRegistryRangeSpec(currentSpec)) {
|
|
255
|
+
const why = reason !== null && reason !== undefined && reason !== ''
|
|
256
|
+
? `${reason};该包是 registry 来源(${currentSpec}),应对齐 lock 而不是改成 link:`
|
|
257
|
+
: `${packageName} 是 registry 来源(${currentSpec}),应对齐 lock 而不是改成 link:`
|
|
258
|
+
return suggestedReconcileAction({ profileDir, profile, reason: why })
|
|
259
|
+
}
|
|
217
260
|
const dir = join(home ?? dshHome(), PLUGIN_SRC_DIR, ...String(packageName).split('/'))
|
|
218
261
|
const spec = linkSpecFor(dir)
|
|
219
262
|
const name = profile ?? profileNameOf(profileDir)
|
|
@@ -144,7 +144,7 @@ async function planDependencySpec({ profileDir, packageName, version, registries
|
|
|
144
144
|
return {
|
|
145
145
|
form: 'link',
|
|
146
146
|
spec: link,
|
|
147
|
-
note: `${packageName}:${why}
|
|
147
|
+
note: `${packageName}:${why};已按**源码安装(本地物化)**记录(${link})—— 不经 npm registry 解析、不经 tarball 完整性校验,pnpm 重建 lock 也能装上`,
|
|
148
148
|
suggestion: null,
|
|
149
149
|
}
|
|
150
150
|
}
|
|
@@ -214,7 +214,7 @@ async function declareProfileDependency(profileDir, packageName, version = null,
|
|
|
214
214
|
? {
|
|
215
215
|
form: 'link',
|
|
216
216
|
spec: canonicalSourceSpec(realSpec),
|
|
217
|
-
note: `${packageName}
|
|
217
|
+
note: `${packageName}:**源码安装(本地物化)** —— 该包在 npm registry 上找不到,只能拿到源码;已按路径型来源记录(${realSpec})。不经 registry 解析、不经 tarball 完整性校验,缺失依赖与框架 peer 由 link 自足性补齐`,
|
|
218
218
|
suggestion: null,
|
|
219
219
|
}
|
|
220
220
|
: await planDependencySpec({ profileDir, packageName, version: resolved, registries, probe, home, profile, sourceSpec: installSpec })
|
|
@@ -226,7 +226,21 @@ async function declareProfileDependency(profileDir, packageName, version = null,
|
|
|
226
226
|
sourceInstall = await installSourceDependency({ profileDir, spec: installSpec, registries, install: installSource })
|
|
227
227
|
recordSourceSpec({ packageName, spec: installSpec, kind: sourceKind, install: sourceInstall, ...homeOpt })
|
|
228
228
|
if (sourceInstall.ok !== true) {
|
|
229
|
-
|
|
229
|
+
// ★ 0.5.72(用户 2026-10-18 批准):**不再回落成 link:**。
|
|
230
|
+
// 有真实来源规格却装不上 ⇒ 如实报错、**不写清单、不建 link**;
|
|
231
|
+
// 偷偷挂一份本地副本会让人以为"装上了",而依赖与 peer 其实没有被包管理器保证过。
|
|
232
|
+
// 仍保留「一键真装」动作:给的是"重试真实通道"的出路,不是假装成功。
|
|
233
|
+
const why = typeof sourceInstall.error === 'string' && sourceInstall.error !== '' ? sourceInstall.error : '(原因未记录)'
|
|
234
|
+
return {
|
|
235
|
+
...empty,
|
|
236
|
+
version: resolved,
|
|
237
|
+
form: plan.form,
|
|
238
|
+
reason: `真装失败:${why}`,
|
|
239
|
+
sourceInstall,
|
|
240
|
+
depNote: `${packageName}:已获取源码但**真装失败**(${why})—— 未改动清单,也未按 link: 记录。` +
|
|
241
|
+
`可点「一键真装」重试;或改用「从本地目录安装」显式走开发式链接。`,
|
|
242
|
+
suggested: suggestedRealInstallAction({ packageName, spec: installSpec, profile, profileDir }),
|
|
243
|
+
}
|
|
230
244
|
}
|
|
231
245
|
}
|
|
232
246
|
const installNote = composeSourceInstallNote({ packageName, install: sourceInstall, plan })
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// L1 · domain —— 「已安装·待重启」的**持久化**(0.5.70 加法)
|
|
2
|
+
//
|
|
3
|
+
// 用户 2026-10-18:「那个重启后生效徽章你不是有吗…memory 插件不就是这样的吗之前」
|
|
4
|
+
// —— 徽章确实早就有(revoke.js#pendingRestartJobs),但它的数据源是**内存里的 installJobs**,
|
|
5
|
+
// 宿主一重启就清空 ⇒ 更新完插件、重启一次之后,徽章连同那条记录一起消失,插件看起来"没了"。
|
|
6
|
+
// 实测:installJobs=0 / pendingRestart=0 / recentFailures=0,而包其实已经更新成功(0.7.2 → 0.7.8)。
|
|
7
|
+
//
|
|
8
|
+
// 本模块把"待重启"落成一份**小文件**(按包名),于是它能活过重启:
|
|
9
|
+
// · 写入:/state 计算时,若某条 done 作业的包**不在运行中的 entries**,就记下来(仅在集合变化时写盘);
|
|
10
|
+
// · 读取:重启后从文件里读回来,仍然显示徽章;
|
|
11
|
+
// · 清除:一旦该包出现在 entries 里(说明已经加载生效),自动从文件里删掉 —— 不留陈旧记录。
|
|
12
|
+
//
|
|
13
|
+
// 严格只读原则的例外说明:这里确实写盘,但写的是**控制台自己的状态文件**(不是 profile、不是 lock),
|
|
14
|
+
// 且只在集合变化时写一次;任何异常都不抛出,降级为"没有持久记录"(徽章退化到旧行为,不比现在更差)。
|
|
15
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
|
|
16
|
+
import { dirname, join } from 'node:path'
|
|
17
|
+
import { dshHome } from '../infra/paths.js'
|
|
18
|
+
|
|
19
|
+
const PENDING_RESTART_FILE = 'pending-restart.json'
|
|
20
|
+
|
|
21
|
+
function pendingRestartPath(home = undefined) {
|
|
22
|
+
return join(home ?? dshHome(), 'plugin-console', PENDING_RESTART_FILE)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const readJsonSafe = (file) => {
|
|
26
|
+
try { return JSON.parse(readFileSync(file, 'utf8')) } catch { return null }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 读持久记录 → [{ packageName, version, at }](读不到/坏文件 = 空数组)。 */
|
|
30
|
+
function readPendingRestart({ home = undefined } = {}) {
|
|
31
|
+
const value = readJsonSafe(pendingRestartPath(home))
|
|
32
|
+
const list = value !== null && typeof value === 'object' && Array.isArray(value.items) ? value.items : []
|
|
33
|
+
return list
|
|
34
|
+
.filter((x) => x !== null && x !== undefined && typeof x.packageName === 'string' && x.packageName !== '')
|
|
35
|
+
.map((x) => ({ packageName: x.packageName, version: typeof x.version === 'string' ? x.version : null, at: Number.isFinite(Number(x.at)) ? Number(x.at) : null }))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 把当前"待重启"集合写穿到文件(**集合不变就不写盘**)。
|
|
40
|
+
* @returns {{ changed: boolean, items: Array }}
|
|
41
|
+
*/
|
|
42
|
+
function syncPendingRestart(items, { home = undefined } = {}) {
|
|
43
|
+
const next = (Array.isArray(items) ? items : [])
|
|
44
|
+
.filter((x) => x !== null && x !== undefined && typeof x.packageName === 'string' && x.packageName !== '')
|
|
45
|
+
.map((x) => ({ packageName: x.packageName, version: typeof x.version === 'string' ? x.version : null, at: Number.isFinite(Number(x.at)) ? Number(x.at) : Date.now() }))
|
|
46
|
+
const prev = readPendingRestart({ home })
|
|
47
|
+
const key = (list) => list.map((x) => `${x.packageName}@${x.version ?? '?'}`).sort().join('|')
|
|
48
|
+
if (key(prev) === key(next)) return { changed: false, items: prev }
|
|
49
|
+
try {
|
|
50
|
+
const file = pendingRestartPath(home)
|
|
51
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
52
|
+
writeFileSync(file, `${JSON.stringify({ items: next }, null, 2)}\n`, 'utf8')
|
|
53
|
+
return { changed: true, items: next }
|
|
54
|
+
} catch {
|
|
55
|
+
// 写不进去就如实退化:本次仍返回新集合(界面照常显示),只是活不过重启
|
|
56
|
+
return { changed: false, items: next, writeError: true }
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 合并:内存里刚判定的待重启 + 文件里历史遗留的(**已经在 entries 里出现过的一律丢弃**)。
|
|
62
|
+
* @param {Array} fresh 内存判定结果(pendingRestartJobs 的输出)
|
|
63
|
+
* @param {Set<string>} liveNames 当前运行中的包名集合
|
|
64
|
+
*/
|
|
65
|
+
function mergePendingRestart(fresh, liveNames, { home = undefined } = {}) {
|
|
66
|
+
const live = liveNames instanceof Set ? liveNames : new Set()
|
|
67
|
+
const persisted = readPendingRestart({ home }).filter((x) => !live.has(x.packageName))
|
|
68
|
+
const merged = new Map()
|
|
69
|
+
for (const x of persisted) merged.set(x.packageName, { packageName: x.packageName, version: x.version ?? null, at: x.at ?? null, persisted: true })
|
|
70
|
+
for (const x of Array.isArray(fresh) ? fresh : []) {
|
|
71
|
+
if (live.has(x.packageName)) continue
|
|
72
|
+
merged.set(x.packageName, { ...x, at: Date.now(), persisted: false })
|
|
73
|
+
}
|
|
74
|
+
const items = [...merged.values()]
|
|
75
|
+
// 写穿:把"合并后的待重启集合"作为新基线(集合不变则不写盘)
|
|
76
|
+
syncPendingRestart(items.map((x) => ({ packageName: x.packageName, version: x.version ?? null, at: x.at ?? null })), { home })
|
|
77
|
+
return items
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export { PENDING_RESTART_FILE, pendingRestartPath, readPendingRestart, syncPendingRestart, mergePendingRestart }
|
|
@@ -386,7 +386,7 @@ async function reconcileLockfile({ profileDir, packageName = null, packages = nu
|
|
|
386
386
|
|
|
387
387
|
const depNotes = []
|
|
388
388
|
// 物化 + link: 计划(registry 查无此包时唯一安全的写回形式)。
|
|
389
|
-
// ★ 2026-09-27 改错:这里**只产出"打算写什么"和"为什么"**,不再抢先断言"
|
|
389
|
+
// ★ 2026-09-27 改错:这里**只产出"打算写什么"和"为什么"**,不再抢先断言"已按源码安装(本地物化)记录依赖"——
|
|
390
390
|
// 旧版在 pnpm add 之前就把这句写进 depNote,于是"写回失败/被后一步覆盖"时面板仍然报"已钉住",
|
|
391
391
|
// 而磁盘上留着的是裸版本号(真机 desktop profile 的实际状态)。最终文案一律在写回之后**读回真实值**再生成。
|
|
392
392
|
const linkPlan = (row, why) => {
|
|
@@ -496,7 +496,7 @@ async function reconcileLockfile({ profileDir, packageName = null, packages = nu
|
|
|
496
496
|
const wrote = writeOf.get(item.name) ?? null
|
|
497
497
|
if (wrote !== null && (wrote.changed === true || wrote.unchanged === true)) {
|
|
498
498
|
if (item.reason !== null) {
|
|
499
|
-
depNotes.push(`${item.name}:${item.reason}
|
|
499
|
+
depNotes.push(`${item.name}:${item.reason},已按源码安装(本地物化)记录依赖(${actual})—— lock 的 importer 条目已**定点写入**(${wrote.version ?? ''}),不经 npm registry 解析、不经 tarball 完整性校验`)
|
|
500
500
|
}
|
|
501
501
|
continue
|
|
502
502
|
}
|
|
@@ -513,7 +513,7 @@ async function reconcileLockfile({ profileDir, packageName = null, packages = nu
|
|
|
513
513
|
}
|
|
514
514
|
if (actual === item.spec) {
|
|
515
515
|
if (item.spec.startsWith('link:') && item.reason !== null) {
|
|
516
|
-
depNotes.push(`${item.name}:${item.reason}
|
|
516
|
+
depNotes.push(`${item.name}:${item.reason},已按源码安装(本地物化)记录依赖(${actual})—— 不经 npm registry 解析、不经 tarball 完整性校验,pnpm 重建 lock 也能装上`)
|
|
517
517
|
}
|
|
518
518
|
continue
|
|
519
519
|
}
|
|
@@ -60,13 +60,33 @@ function dropStaleUpdateCandidates(candidates, packageName, baseUrl, profileDir)
|
|
|
60
60
|
* @param {{ latest?: string|null, next?: string|null, beta?: string|null }} candidates
|
|
61
61
|
* @returns {string|null} 目标版本;全空 ⇒ null(真的没有更高版本)
|
|
62
62
|
*/
|
|
63
|
-
|
|
63
|
+
/**
|
|
64
|
+
* 从 { latest, next, beta } 里选出**该更新的目标版本**。
|
|
65
|
+
* ★ 0.5.74(用户 2026-10-18):按**版本高低**选,不再按通道顺序取第一个非空。
|
|
66
|
+
* 真机证据:官方包只往 next 发版而 latest 停在远古 alpha ——
|
|
67
|
+
* dsh-experimental-speech-to-text: latest 0.1.7-alpha.1 / next 0.2.1-rc.2
|
|
68
|
+
* 旧实现返回 latest ⇒ 界面永远"已是最新版",next 里的新版永远看不见。
|
|
69
|
+
* 比较**不做第二份实现**:共用 infra/semver.js#pickNewerSemver(本模块既有的唯一判据)。
|
|
70
|
+
*
|
|
71
|
+
* @param {{latest?:string,next?:string,beta?:string}} candidates
|
|
72
|
+
* @param {{installedVersion?:string|null}} [options] 给了就优先选**严格高于它**的;没有更高的则退回全体最高
|
|
73
|
+
*/
|
|
74
|
+
function pickUpdateTarget(candidates, options = {}) {
|
|
64
75
|
const src = candidates !== null && typeof candidates === 'object' ? candidates : {}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
76
|
+
const list = ['latest', 'next', 'beta']
|
|
77
|
+
.map((key) => src[key])
|
|
78
|
+
.filter((value) => typeof value === 'string' && value !== '')
|
|
79
|
+
if (list.length === 0) return null
|
|
80
|
+
const installed = typeof options?.installedVersion === 'string' && options.installedVersion !== ''
|
|
81
|
+
? options.installedVersion
|
|
82
|
+
: null
|
|
83
|
+
// ① 有已装版本:取严格高于它的最高者
|
|
84
|
+
if (installed !== null) {
|
|
85
|
+
const newer = pickNewerSemver(installed, list)
|
|
86
|
+
if (newer !== null) return newer
|
|
68
87
|
}
|
|
69
|
-
|
|
88
|
+
// ② 没有更高的(或根本没给已装版本):取全体最高的 —— 以 0.0.0 为基准即可(唯一判据仍是同一个函数)
|
|
89
|
+
return pickNewerSemver('0.0.0', list)
|
|
70
90
|
}
|
|
71
91
|
|
|
72
92
|
export { dropStaleUpdateCandidates, installedVersionOf, pickUpdateTarget }
|
|
@@ -27,6 +27,7 @@ import { routeGithubLogin, routeGithubOpenLogin } from './github-login.js'
|
|
|
27
27
|
import { routeInstall, routeInstallStatus } from './install.js'
|
|
28
28
|
import { routeLockfileCheck, routeLockfileRepair } from './lockfile.js'
|
|
29
29
|
import { routeEnrich, routeMarketIndex, routeRepo, routeSearch, routeSubpackages } from './market.js'
|
|
30
|
+
import { routeRevokeForce } from './revoke-force.js'
|
|
30
31
|
import { routeAdaptUnlock, routeAdaptUnlockAll, routeCleanResiduals, routeSelfUpdate, routeToggle, routeUninstall } from './plugins.js'
|
|
31
32
|
import { routeSkillRemove, routeSkillToggle, routeSkillsInstalledGet } from './skills.js'
|
|
32
33
|
import { routeGiteeOauthCallbackGet, routeGiteeOauthUrlGet, routeRegistryScan, routeSources, routeSourcesGet } from './sources.js'
|
|
@@ -130,6 +131,7 @@ const ROUTES = [
|
|
|
130
131
|
{ methods: ['POST'], path: `${ROUTE_PREFIX}/toggle`, handler: routeToggle },
|
|
131
132
|
{ methods: ['POST'], path: `${ROUTE_PREFIX}/uninstall`, handler: routeUninstall },
|
|
132
133
|
{ methods: ['POST'], path: `${ROUTE_PREFIX}/adapt-unlock`, handler: routeAdaptUnlock },
|
|
134
|
+
{ methods: ['POST'], path: `${ROUTE_PREFIX}/revoke-force`, handler: routeRevokeForce },
|
|
133
135
|
{ methods: ['POST'], path: `${ROUTE_PREFIX}/adapt-unlock-all`, handler: routeAdaptUnlockAll },
|
|
134
136
|
{ methods: ['POST'], path: `${ROUTE_PREFIX}/clean-residuals`, handler: routeCleanResiduals },
|
|
135
137
|
{ methods: ['POST'], path: `${ROUTE_PREFIX}/self-update`, handler: routeSelfUpdate },
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// L2 · routes —— 撤销「无效的强制启用覆盖」(0.5.76,用户 2026-10-18)
|
|
2
|
+
//
|
|
3
|
+
// 场景:补丁里写了 `disabled: false`(强制开启),但该行运行时根本没加载、也不是 peer 否决造成的
|
|
4
|
+
// —— 这条覆盖是**死的**,留着只会让界面继续显示「强制启用未生效」而毫无作用。
|
|
5
|
+
//
|
|
6
|
+
// 语义(写死):
|
|
7
|
+
// · 只对**确实在 forced 列表**里的行生效 —— 不凭空改别人的补丁;
|
|
8
|
+
// · 复用 patch.js#removeDisableBlock(与 adapt-unlock 同一实现,不写第二份);
|
|
9
|
+
// · 移除后**不承诺"已启用"**:撤掉覆盖只代表不再强压,实际是否加载由框架决定。
|
|
10
|
+
import { existsSync } from 'node:fs'
|
|
11
|
+
import { findPatchPath } from '../infra/paths.js'
|
|
12
|
+
import { removeDisableBlock, readPatchState } from '../domain/patch.js'
|
|
13
|
+
import { sendError, sendJson } from '../infra/httpd.js'
|
|
14
|
+
|
|
15
|
+
async function routeRevokeForce(req, res, rc) {
|
|
16
|
+
const body = rc.body !== null && typeof rc.body === 'object' ? rc.body : {}
|
|
17
|
+
const entryId = typeof body.entryId === 'string' ? body.entryId : ''
|
|
18
|
+
if (entryId === '') { sendError(res, 400, '缺少 entryId'); return }
|
|
19
|
+
const rowId = entryId.includes(':') ? entryId.split(':').slice(1).join(':') : entryId
|
|
20
|
+
let patchPath = null
|
|
21
|
+
try { patchPath = findPatchPath(rc.ctx) } catch { patchPath = null }
|
|
22
|
+
if (typeof patchPath !== 'string' || patchPath === '' || !existsSync(patchPath)) {
|
|
23
|
+
sendError(res, 400, '当前 profile 没有可写的补丁文件'); return
|
|
24
|
+
}
|
|
25
|
+
let forced = []
|
|
26
|
+
try { const state = await readPatchState(patchPath); forced = Array.isArray(state?.forced) ? state.forced : [] } catch { forced = [] }
|
|
27
|
+
if (!forced.includes(rowId)) {
|
|
28
|
+
sendError(res, 400, `${rowId} 当前不是「补丁强制启用」状态(没有 disabled: false 覆盖块),无需撤销`); return
|
|
29
|
+
}
|
|
30
|
+
try { await removeDisableBlock(patchPath, rowId) }
|
|
31
|
+
catch (error) { sendError(res, 500, `撤销失败:${error instanceof Error ? error.message : String(error)}`); return }
|
|
32
|
+
sendJson(res, 200, { ok: true, rowId, revoked: true, note: `已移除 ${rowId} 的强制启用覆盖(disabled: false)。该行是否加载由框架决定,本次未改清单。` })
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export { routeRevokeForce }
|
|
@@ -22,6 +22,7 @@ import { installJobView, readGithubAuth } from '../domain/install.js'
|
|
|
22
22
|
import { readPluginDetails } from '../domain/market.js'
|
|
23
23
|
import { healPatchSafety, readPatchState } from '../domain/patch.js'
|
|
24
24
|
import { pendingRestartJobs } from '../domain/revoke.js'
|
|
25
|
+
import { mergePendingRestart } from '../domain/pending-restart.js'
|
|
25
26
|
import { listEntries } from '../domain/runtime.js'
|
|
26
27
|
import { sendError, sendJson } from '../infra/httpd.js'
|
|
27
28
|
import { dshHome, entryPkgMeta, findPatchPath, pluginRoot, profileDirOf, rowIdOf } from '../infra/paths.js'
|
|
@@ -97,7 +98,7 @@ async function routeStateGet(req, res, rc) {
|
|
|
97
98
|
repository: meta?.repository ?? null,
|
|
98
99
|
// v0.3.45:只有"补丁里此刻确实还禁着它"的行才显示【待适配】——
|
|
99
100
|
// 记录与开关脱节时(用户手动启用过 / 补丁块被清掉)不能继续挂着「待适配」误导人
|
|
100
|
-
pendingCompat: compatPendingRows.has(entry.rowId) && disabledByPatch,
|
|
101
|
+
pendingCompat: (compatPendingRows.has(entry.rowId) && disabledByPatch) || veto !== null,
|
|
101
102
|
// 0.5.34 加法:记录的 `fail` 若已被**当前判据**撤回(旧误报),面板据此不再说「待适配」,
|
|
102
103
|
// 改说「判据已撤回·可解锁」并给一键解锁入口(判据本身一个字都没放宽:真 fail 仍 fail)
|
|
103
104
|
compatRescinded: compatPendingRows.has(entry.rowId) && verdict?.rescinded === true,
|
|
@@ -108,6 +109,14 @@ async function routeStateGet(req, res, rc) {
|
|
|
108
109
|
adoptable: adoptable.get(entry.rowId) ?? null,
|
|
109
110
|
// 框架 peer 预检的本地复算结果(null = 无话可说;只在这类行上下发,不新增常驻面板行)
|
|
110
111
|
veto,
|
|
112
|
+
// ★ 0.5.76(用户 2026-10-18):**禁用出处** + 无效强制覆盖的可见性(只加字段,不改既有判定)
|
|
113
|
+
// 'patch' = 本控制台补丁禁的(删块即可正常启用)
|
|
114
|
+
// 'bundle' = 插件自身/框架默认禁的(我们只能写 disabled:false 覆盖 ⇒ 补丁强制启用)
|
|
115
|
+
disableSource: !entry.enabled && entry.fiberPhase === null
|
|
116
|
+
? (disabledByPatch ? 'patch' : 'bundle')
|
|
117
|
+
: null,
|
|
118
|
+
// 强制开启写了、但运行时仍没起来,且不是 peer 否决造成的 ⇒ 这条覆盖是**无效的**,要说出来
|
|
119
|
+
forcedNotLoaded: patch.forced.includes(entry.rowId) && entry.enabled !== true && veto === null,
|
|
111
120
|
}
|
|
112
121
|
})
|
|
113
122
|
const compat = await detectCompat(ctx.baseUrl ?? 'file:///')
|
|
@@ -122,7 +131,23 @@ async function routeStateGet(req, res, rc) {
|
|
|
122
131
|
// 已安装但尚未生效的任务(2026-09-20 真装真卸演练实测):bundle 型插件要重启才被加载,
|
|
123
132
|
// 装完 /install 返回 entryId: null、/state 里新增 loader 条目 = 0 —— 面板过去完全看不出
|
|
124
133
|
// 「装了但还没生效」,也无法撤销。前端据此渲染「已安装·重启后生效」徽标 + 按 jobId 删除。
|
|
125
|
-
|
|
134
|
+
// ★ 0.5.70(用户 2026-10-18):徽章要能活过重启 —— 内存判定的结果与**持久记录**合并;
|
|
135
|
+
// 已经在 entries 里出现过的包会自动从持久记录里清掉(不留陈旧项)。
|
|
136
|
+
const livePackageNames = (() => {
|
|
137
|
+
const set = new Set()
|
|
138
|
+
try {
|
|
139
|
+
for (const e of listEntries(ctx)) {
|
|
140
|
+
const mn = typeof e?.moduleName === 'string' ? e.moduleName : ''
|
|
141
|
+
if (mn === '' || mn.startsWith('cordis:')) continue
|
|
142
|
+
set.add(mn)
|
|
143
|
+
const m = /^(@[^/]+\/[^/]+)(?:\/.*)?$/u.exec(mn)
|
|
144
|
+
if (m) set.add(m[1])
|
|
145
|
+
}
|
|
146
|
+
} catch {}
|
|
147
|
+
return set
|
|
148
|
+
})()
|
|
149
|
+
let pendingRestart = []
|
|
150
|
+
try { pendingRestart = mergePendingRestart(pendingRestartJobs([...installJobs.values()], listEntries(ctx)), livePackageNames) } catch { pendingRestart = pendingRestartJobs([...installJobs.values()], listEntries(ctx)) }
|
|
126
151
|
// 框架升级检测与适配(备份快照 + 重打框架补丁),try 包裹不阻塞 state 返回
|
|
127
152
|
let framework = null
|
|
128
153
|
try { framework = detectFrameworkUpgrade(ctx) } catch {}
|
|
@@ -205,12 +230,15 @@ async function routeStateGet(req, res, rc) {
|
|
|
205
230
|
|
|
206
231
|
// ★ 0.5.55 加法:把已经算好的各块**折成一行健康摘要**(不新增 IO、不新增判据)。
|
|
207
232
|
// 面板 176 个按钮、信息散在六处,用户没有一个地方能回答"整体健康吗、我该先处理哪件"。
|
|
233
|
+
const liveRowIds = new Set(entries.map((e) => e.rowId))
|
|
208
234
|
let health = { issues: 0, items: [] }
|
|
209
235
|
try {
|
|
210
236
|
health = buildInstallHealth({
|
|
211
237
|
entries,
|
|
212
238
|
skippedBundles,
|
|
213
|
-
|
|
239
|
+
// ★ 0.5.68:与界面同口径 —— 只统计「仍待适配」且**目标行仍存在**的记录。
|
|
240
|
+
// 否则会把已采纳的历史记录、以及指向已删除行的陈旧记录都算成"待处理"(实测 24 vs 界面 0)。
|
|
241
|
+
pendingCompat: (compatPending?.pending ?? []).filter((p) => (p?.status ?? 'pending') === 'pending' && liveRowIds.has(p?.rowId)),
|
|
214
242
|
patchUncertain: patchHeal === null ? [] : (patchHeal.uncertain ?? []),
|
|
215
243
|
})
|
|
216
244
|
} catch {}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@noob-stupid/dsh-plugin-console",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.77",
|
|
4
4
|
"description": "DSH 框架升级安全与插件升级门控:一键升级、失败自动回滚、升级后回滚上版、旧插件不适配自动禁用;内置多源插件市场为发现层,插件源全部可自定义,可指向公司内网私有源 / 私有索引 / 本地 Git 仓库,纯内网离线可用 | Framework upgrade safety & plugin version gating for DSH, with a customizable multi-source plugin market: point every source at internal mirrors or a local file:// repo for intranet-only, offline installs.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|