@noob-stupid/dsh-plugin-console 0.5.85 → 0.5.87
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 +273 -13
- package/lib/server/domain/dev-roots.js +12 -0
- package/lib/server/domain/install-box.js +77 -0
- package/lib/server/domain/install-channels.js +242 -0
- package/lib/server/domain/install-diagnose.js +9 -4
- package/lib/server/domain/install-job.js +49 -175
- package/lib/server/domain/install-jobs-store.js +3 -0
- package/lib/server/domain/install-version.js +65 -0
- package/lib/server/domain/install.js +17 -70
- package/lib/server/domain/link-inventory.js +41 -4
- package/lib/server/domain/link-self-sufficiency.js +67 -0
- package/lib/server/domain/manifest.js +17 -3
- package/lib/server/domain/persist.js +5 -0
- package/lib/server/domain/selfupdate.js +6 -1
- package/lib/server/domain/unpin-dev-dir.js +149 -0
- package/lib/server/routes/index.js +13 -3
- package/lib/server/routes/link-actions.js +120 -0
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -142,11 +142,31 @@ window.__ModuleLoader__.load({
|
|
|
142
142
|
linksToggleSummary: "开发式链接(link):共 {n} 个,自足 {ready} 个",
|
|
143
143
|
linksToggleTitle: "展开/收起 link 明细(默认收起)",
|
|
144
144
|
// 0.5.84 加法(用户第 6 项):功能包工具栏里的 link 入口 + 它打开的那块面板
|
|
145
|
-
linksBtn: "
|
|
145
|
+
linksBtn: "link",
|
|
146
146
|
linksPanelTitle: "开发式链接(link:)—— 直接展示 /plugin-console/links 的只读清单:指向哪、目标还在不在、依赖/peer 齐不齐、垫片在不在",
|
|
147
147
|
linksPanelLoading: "正在读取…",
|
|
148
148
|
linksPanelReload: "重新读取",
|
|
149
149
|
linksPanelEmpty: "没有 link: 钉住的插件(这份清单只列开发式链接)",
|
|
150
|
+
// 0.5.87 加法(用户 2026-10-18):link 面板从「只读清单」升级为「可操作」——
|
|
151
|
+
// 顶部选开发区 + 两段式钉住;每行按服务端结论给动作(缺依赖 / 缺 peer / 取消钉住)。
|
|
152
|
+
linksDevRootLabel: "开发区:",
|
|
153
|
+
linksDevRootPick: "选择已登记的开发区…",
|
|
154
|
+
linksDirPlaceholder: "要钉住的目录(绝对路径,必须在某个已登记开发区里)",
|
|
155
|
+
linksBrowse: "浏览…",
|
|
156
|
+
linksPinPlanBtn: "钉住",
|
|
157
|
+
linksPinPlanTitle: "{p} 将被钉成 link: 依赖,操作如下(还没动任何东西)",
|
|
158
|
+
linksPinConfirmBtn: "确认钉住",
|
|
159
|
+
linksCancel: "取消",
|
|
160
|
+
linksPinnedDone: "已钉住 {p}",
|
|
161
|
+
linksMtime: "改动于",
|
|
162
|
+
linksMtimeHint: "「改动于」= 目标目录本身或它直接下一层(不含 node_modules / .git)里最近的修改时间;更深的文件改动不会体现在这里",
|
|
163
|
+
linksRepairDeps: "补齐依赖",
|
|
164
|
+
linksRepairShims: "造垫片",
|
|
165
|
+
linksRepairDone: "已补齐",
|
|
166
|
+
linksUnpinBtn: "取消钉住",
|
|
167
|
+
linksUnpinConfirmBtn: "确认取消钉住",
|
|
168
|
+
linksUnpinDone: "已取消钉住",
|
|
169
|
+
linksBusyWorking: "处理中…",
|
|
150
170
|
devRootsTitle: "开发区根(只有这些根里的目录能钉住)",
|
|
151
171
|
devRootsNone: "(未登记)",
|
|
152
172
|
devRootAddPlaceholder: "注册新的开发区根(绝对路径,例如 D:\\my-plugins)",
|
|
@@ -785,11 +805,29 @@ window.__ModuleLoader__.load({
|
|
|
785
805
|
linksToggleBtn: "Dev link (link:) details",
|
|
786
806
|
linksToggleSummary: "Dev links (link:): {n} total, {ready} self-sufficient",
|
|
787
807
|
linksToggleTitle: "Expand/collapse link details (collapsed by default)",
|
|
788
|
-
linksBtn: "
|
|
808
|
+
linksBtn: "link",
|
|
789
809
|
linksPanelTitle: "Dev links (link:) - the read-only list from /plugin-console/links: where it points, whether the target still exists, whether deps/peers are complete, and whether shims are in place",
|
|
790
810
|
linksPanelLoading: "Reading...",
|
|
791
811
|
linksPanelReload: "Reload",
|
|
792
812
|
linksPanelEmpty: "No plugin is pinned with link: (this list only covers dev links)",
|
|
813
|
+
linksDevRootLabel: "Dev root:",
|
|
814
|
+
linksDevRootPick: "Pick a registered dev root...",
|
|
815
|
+
linksDirPlaceholder: "Directory to pin (absolute path, must be inside a registered dev root)",
|
|
816
|
+
linksBrowse: "Browse...",
|
|
817
|
+
linksPinPlanBtn: "Pin",
|
|
818
|
+
linksPinPlanTitle: "{p} will be pinned as a link: dependency - steps below (nothing has changed yet)",
|
|
819
|
+
linksPinConfirmBtn: "Confirm pin",
|
|
820
|
+
linksCancel: "Cancel",
|
|
821
|
+
linksPinnedDone: "Pinned {p}",
|
|
822
|
+
linksMtime: "changed",
|
|
823
|
+
linksMtimeHint: "'changed' = the newest mtime of the target directory itself or its direct children (node_modules / .git excluded); deeper file edits do not show up here",
|
|
824
|
+
linksRepairDeps: "Install deps",
|
|
825
|
+
linksRepairShims: "Create shims",
|
|
826
|
+
linksRepairDone: "Repaired",
|
|
827
|
+
linksUnpinBtn: "Unpin",
|
|
828
|
+
linksUnpinConfirmBtn: "Confirm unpin",
|
|
829
|
+
linksUnpinDone: "Unpinned",
|
|
830
|
+
linksBusyWorking: "Working...",
|
|
793
831
|
devRootsTitle: "Dev roots (only directories inside these can be pinned)",
|
|
794
832
|
devRootsNone: "(none registered)",
|
|
795
833
|
devRootAddPlaceholder: "Register a dev root (absolute path, e.g. D:\\my-plugins)",
|
|
@@ -1995,6 +2033,40 @@ function healthSignature(health) {
|
|
|
1995
2033
|
return false;
|
|
1996
2034
|
}
|
|
1997
2035
|
}
|
|
2036
|
+
// ★★ 0.5.87 加法:宿主**真的**有没有系统目录对话框?★★
|
|
2037
|
+
// 判据只有一条、且必须是**正面证据**:桌面端外壳通过 contextBridge 注入的
|
|
2038
|
+
// `window.__DSH_DIRECTORY_PICKER__.pick()`(Electron 主进程 dialog.showOpenDialog)。
|
|
2039
|
+
// 为什么不能"先摆一个按钮、点了再说":用户 2026-10-18 的话是「点了没反应」算缺陷 ——
|
|
2040
|
+
// web 端(浏览器直接打开控制台)没有这个桥,那种按钮点下去只会抛个没意义的错。
|
|
2041
|
+
// 所以:有桥才渲染「浏览…」,没有就**不渲染**(不是灰掉,是根本不出现)。
|
|
2042
|
+
// 放在模块级(不放组件里):与 skippedSignature / healthSignature 同一条纪律 ——
|
|
2043
|
+
// 主面板不许依赖"只属于某个子组件"的局部东西。
|
|
2044
|
+
let directoryPicker = null;
|
|
2045
|
+
function detectDirectoryPicker() {
|
|
2046
|
+
try {
|
|
2047
|
+
const bridge = typeof globalThis === "undefined" ? null : globalThis.__DSH_DIRECTORY_PICKER__;
|
|
2048
|
+
if (bridge !== null && typeof bridge === "object" && typeof bridge.pick === "function") return () => bridge.pick();
|
|
2049
|
+
} catch {}
|
|
2050
|
+
return null;
|
|
2051
|
+
}
|
|
2052
|
+
/** 目标目录的修改时间:ISO → 本地可读。解析不了就原样回显 —— 绝不编造一个时间。 */
|
|
2053
|
+
function formatMtimeText(iso) {
|
|
2054
|
+
try {
|
|
2055
|
+
const when = new Date(String(iso));
|
|
2056
|
+
if (Number.isNaN(when.getTime())) return String(iso);
|
|
2057
|
+
return when.toLocaleString();
|
|
2058
|
+
} catch { return String(iso); }
|
|
2059
|
+
}
|
|
2060
|
+
/** 从下拉里选了一个开发区 → 补成"待续写"的草稿路径。
|
|
2061
|
+
* 为什么:**根本身不能当钉住目标**(服务端刻意不允许,领域判据没放宽),
|
|
2062
|
+
* 所以直接塞根本身会让人点了必被拒。补上分隔符,接着写根里的那个插件目录即可。
|
|
2063
|
+
* 分隔符按路径自身风格推断(Windows 的 `\` / 其它 `/`)—— 不猜平台,只看字符串。 */
|
|
2064
|
+
function devRootDraft(root) {
|
|
2065
|
+
const text = String(root ?? "");
|
|
2066
|
+
if (text === "") return "";
|
|
2067
|
+
if (/[\\/]$/u.test(text)) return text;
|
|
2068
|
+
return text + (text.includes("\\") ? "\\" : "/");
|
|
2069
|
+
}
|
|
1998
2070
|
function PluginConsoleTab({ t, section = null }) {
|
|
1999
2071
|
const [state, setState] = react.useState({ status: "loading" });
|
|
2000
2072
|
// ★ 0.5.59:健康摘要**可关掉**(用户 2026-10-17「这个提示可以关掉」)。
|
|
@@ -2379,6 +2451,106 @@ function healthSignature(health) {
|
|
|
2379
2451
|
setLinksPanelData({ ok: false, items: [], count: 0, readyCount: 0, error: (error && error.message) || t("safetySwitchFailed") });
|
|
2380
2452
|
} finally { setLinksPanelBusy(false); }
|
|
2381
2453
|
};
|
|
2454
|
+
// ★★ 0.5.87 加法:把 link 面板从「只读清单」升级为「可操作」★★
|
|
2455
|
+
// 边界(写死):**判据全部在服务端**(/links 给 ready / 缺什么 / 目标 / 改动时间),
|
|
2456
|
+
// 这里只做三件事:选开发区 → 两段式钉住(先只读计划、确认后才 apply)、
|
|
2457
|
+
// 行内动作 → 调对应写接口 → 重新读取。客户端不重算任何"缺什么 / 能不能补"。
|
|
2458
|
+
const [linksDevRoots, setLinksDevRoots] = react.useState([]);
|
|
2459
|
+
const [linksDevRootsNote, setLinksDevRootsNote] = react.useState(null);
|
|
2460
|
+
const [linksDir, setLinksDir] = react.useState("");
|
|
2461
|
+
const [linksPinPlan, setLinksPinPlan] = react.useState(null);
|
|
2462
|
+
const [linksPinBusy, setLinksPinBusy] = react.useState(false);
|
|
2463
|
+
const [linksRowBusy, setLinksRowBusy] = react.useState(null);
|
|
2464
|
+
const [linksRowNote, setLinksRowNote] = react.useState({});
|
|
2465
|
+
const [linksUnpinPlan, setLinksUnpinPlan] = react.useState(null);
|
|
2466
|
+
// 「浏览…」只在宿主**确实**提供系统目录对话框时才存在(见模块级 detectDirectoryPicker)
|
|
2467
|
+
const linksCanBrowse = typeof directoryPicker === "function";
|
|
2468
|
+
/** 已登记开发区(来自 /dev-roots 的只读 list 分支;登记表坏了要如实说,不静默成空)。 */
|
|
2469
|
+
const linksDevRootsLoad = async () => {
|
|
2470
|
+
try {
|
|
2471
|
+
const data = await call("/plugin-console/dev-roots", { action: "list" });
|
|
2472
|
+
setLinksDevRoots(Array.isArray(data && data.roots) ? data.roots : []);
|
|
2473
|
+
setLinksDevRootsNote(data && Array.isArray(data.warnings) && data.warnings.length > 0 ? data.warnings.join(";") : null);
|
|
2474
|
+
} catch (error) {
|
|
2475
|
+
setLinksDevRoots([]);
|
|
2476
|
+
setLinksDevRootsNote((error && error.message) || t("safetySwitchFailed"));
|
|
2477
|
+
}
|
|
2478
|
+
};
|
|
2479
|
+
const linksBrowse = async () => {
|
|
2480
|
+
if (!linksCanBrowse) return;
|
|
2481
|
+
try {
|
|
2482
|
+
const picked = await directoryPicker();
|
|
2483
|
+
if (typeof picked === "string" && picked !== "") setLinksDir(picked);
|
|
2484
|
+
} catch (error) {
|
|
2485
|
+
setLinksPinPlan({ ok: false, applied: false, error: (error && error.message) || t("safetySwitchFailed") });
|
|
2486
|
+
}
|
|
2487
|
+
};
|
|
2488
|
+
/** 第一段:**只读计划**(服务端 plan 段,一个字节都不写)——先让人看清会发生什么。 */
|
|
2489
|
+
const linksPinPlanLoad = async () => {
|
|
2490
|
+
const dir = linksDir.trim();
|
|
2491
|
+
if (dir === "" || linksPinBusy) return;
|
|
2492
|
+
setLinksPinBusy(true);
|
|
2493
|
+
setLinksPinPlan(null);
|
|
2494
|
+
try {
|
|
2495
|
+
const data = await call("/plugin-console/pin-dev-dir", { dir });
|
|
2496
|
+
setLinksPinPlan({ ok: true, applied: false, plan: data });
|
|
2497
|
+
} catch (error) {
|
|
2498
|
+
setLinksPinPlan({ ok: false, applied: false, error: (error && error.message) || t("safetySwitchFailed") });
|
|
2499
|
+
} finally { setLinksPinBusy(false); }
|
|
2500
|
+
};
|
|
2501
|
+
/** 第二段:用户点了「确认钉住」才真写(前端绝不跳过确认去调 apply)。 */
|
|
2502
|
+
const linksPinConfirm = async () => {
|
|
2503
|
+
const dir = linksDir.trim();
|
|
2504
|
+
if (dir === "" || linksPinBusy) return;
|
|
2505
|
+
setLinksPinBusy(true);
|
|
2506
|
+
try {
|
|
2507
|
+
const data = await call("/plugin-console/pin-dev-dir", { dir, confirm: true });
|
|
2508
|
+
setLinksPinPlan({ ok: true, applied: true, plan: data });
|
|
2509
|
+
await linksPanelLoad();
|
|
2510
|
+
} catch (error) {
|
|
2511
|
+
setLinksPinPlan({ ok: false, applied: false, error: (error && error.message) || t("safetySwitchFailed") });
|
|
2512
|
+
} finally { setLinksPinBusy(false); }
|
|
2513
|
+
};
|
|
2514
|
+
/** 行内动作:deps=补齐依赖 / shims=造垫片。成败都按服务端 note 原话展示。 */
|
|
2515
|
+
const linksRowRun = async (item, kind) => {
|
|
2516
|
+
if (linksRowBusy !== null) return;
|
|
2517
|
+
const name = String(item.packageName);
|
|
2518
|
+
setLinksRowBusy(name + "|" + kind);
|
|
2519
|
+
setLinksRowNote((prev) => ({ ...prev, [name]: t("linksBusyWorking") }));
|
|
2520
|
+
try {
|
|
2521
|
+
const data = await call("/plugin-console/link-repair", { packageName: name, kind });
|
|
2522
|
+
setLinksRowNote((prev) => ({ ...prev, [name]: (data && data.note) || t("linksRepairDone") }));
|
|
2523
|
+
await linksPanelLoad();
|
|
2524
|
+
} catch (error) {
|
|
2525
|
+
setLinksRowNote((prev) => ({ ...prev, [name]: t("safetySwitchFailed") + ":" + ((error && error.message) || "") }));
|
|
2526
|
+
} finally { setLinksRowBusy(null); }
|
|
2527
|
+
};
|
|
2528
|
+
/** 取消钉住的第一段:只读计划(会明说"它还在 bundles 里"这类后果)。 */
|
|
2529
|
+
const linksUnpinPlanLoad = async (item) => {
|
|
2530
|
+
if (linksRowBusy !== null) return;
|
|
2531
|
+
const name = String(item.packageName);
|
|
2532
|
+
setLinksRowBusy(name + "|unpin-plan");
|
|
2533
|
+
setLinksUnpinPlan(null);
|
|
2534
|
+
try {
|
|
2535
|
+
const data = await call("/plugin-console/unpin-dev-dir", { packageName: name });
|
|
2536
|
+
setLinksUnpinPlan({ ok: true, packageName: name, plan: data });
|
|
2537
|
+
} catch (error) {
|
|
2538
|
+
setLinksUnpinPlan({ ok: false, packageName: name, error: (error && error.message) || t("safetySwitchFailed") });
|
|
2539
|
+
} finally { setLinksRowBusy(null); }
|
|
2540
|
+
};
|
|
2541
|
+
const linksUnpinConfirm = async (item) => {
|
|
2542
|
+
if (linksRowBusy !== null) return;
|
|
2543
|
+
const name = String(item.packageName);
|
|
2544
|
+
setLinksRowBusy(name + "|unpin");
|
|
2545
|
+
try {
|
|
2546
|
+
const data = await call("/plugin-console/unpin-dev-dir", { packageName: name, confirm: true });
|
|
2547
|
+
setLinksUnpinPlan(null);
|
|
2548
|
+
setLinksRowNote((prev) => ({ ...prev, [name]: (data && data.note) || t("linksUnpinDone") }));
|
|
2549
|
+
await linksPanelLoad();
|
|
2550
|
+
} catch (error) {
|
|
2551
|
+
setLinksUnpinPlan({ ok: false, packageName: name, error: (error && error.message) || t("safetySwitchFailed") });
|
|
2552
|
+
} finally { setLinksRowBusy(null); }
|
|
2553
|
+
};
|
|
2382
2554
|
// ★ 0.5.80:历史解锁记录默认折叠(用户:新的应覆盖旧的,别一直铺满)
|
|
2383
2555
|
const [gateHistoryOpen, setGateHistoryOpen] = react.useState(false);
|
|
2384
2556
|
// 功能包位置:长按拖动,持久化 localStorage(每次打开沿用上次位置)
|
|
@@ -5024,13 +5196,67 @@ function healthSignature(health) {
|
|
|
5024
5196
|
// ★ 0.5.84(用户第 6 项):功能包 → [开发式链接] 的面板:直接展示 /plugin-console/links
|
|
5025
5197
|
// 的同一份数据(与「升级安全」里那块折叠展示同源)。判据来自服务端,这里只如实展示:
|
|
5026
5198
|
// 每行 = ✓/• + 包名 + 版本 + 指向 + 缺什么(悬停看完整原因)。
|
|
5199
|
+
// ★ 0.5.87 把这份只读清单升级为**可操作**(用户 2026-10-18):顶部选开发区 → 两段式钉住;
|
|
5200
|
+
// 每行按**服务端说的缺什么**给按钮(有哪个显示哪个,没有就不显示),底部照旧「重新读取」。
|
|
5027
5201
|
linksPanelOpen
|
|
5028
5202
|
? el("div", { className: styles.modalBackdrop, onClick: (event) => { if (event.target === event.currentTarget) setLinksPanelOpen(false); } },
|
|
5029
|
-
el("div", { className: styles.modalCard, style: { maxWidth:
|
|
5203
|
+
el("div", { className: styles.modalCard, style: { maxWidth: 720 } },
|
|
5030
5204
|
el("div", { className: styles.rowTop, style: { justifyContent: "space-between" } },
|
|
5031
5205
|
el("strong", { className: styles.name }, t("linksBtn")),
|
|
5032
5206
|
el("button", { type: "button", className: styles.trashBtn, style: { fontSize: 14, padding: "4px 8px" }, title: t("closeModal"), "aria-label": t("closeModal"), onClick: () => setLinksPanelOpen(false) }, "✕")),
|
|
5033
5207
|
el("p", { className: styles.message }, t("linksPanelTitle")),
|
|
5208
|
+
// ── 顶部:选开发区 → [钉住](先只读计划,用户确认后才真写)────────────────
|
|
5209
|
+
el("div", { style: { display: "flex", flexDirection: "column", gap: 6, marginTop: 6, paddingTop: 8, borderTop: "1px solid var(--dsw-alias-border-secondary, rgba(127,127,127,.18))" } },
|
|
5210
|
+
el("div", { style: { display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" } },
|
|
5211
|
+
el("span", { className: styles.status }, t("linksDevRootLabel")),
|
|
5212
|
+
el("select", {
|
|
5213
|
+
"aria-label": t("linksDevRootLabel"),
|
|
5214
|
+
value: "",
|
|
5215
|
+
disabled: linksPinBusy,
|
|
5216
|
+
onChange: (event) => { const picked = String(event.currentTarget.value ?? ""); if (picked !== "") setLinksDir(devRootDraft(picked)); },
|
|
5217
|
+
style: { flex: "0 1 300px", minWidth: 180, font: "inherit", padding: "4px 6px" },
|
|
5218
|
+
},
|
|
5219
|
+
el("option", { value: "" }, t("linksDevRootPick")),
|
|
5220
|
+
linksDevRoots.map((root) => el("option", { key: "dr-" + String(root), value: String(root) }, String(root))))),
|
|
5221
|
+
el("div", { style: { display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" } },
|
|
5222
|
+
el("input", {
|
|
5223
|
+
type: "text",
|
|
5224
|
+
placeholder: t("linksDirPlaceholder"),
|
|
5225
|
+
value: linksDir,
|
|
5226
|
+
disabled: linksPinBusy,
|
|
5227
|
+
"aria-label": t("linksDirPlaceholder"),
|
|
5228
|
+
onChange: (event) => setLinksDir(String(event.currentTarget.value ?? "")),
|
|
5229
|
+
style: { flex: 1, minWidth: 220, font: "inherit", padding: "4px 8px" },
|
|
5230
|
+
}),
|
|
5231
|
+
// ★ 只在宿主**确实**有系统目录对话框时才渲染(web 端没有 ⇒ 不显示,不是灰掉)
|
|
5232
|
+
linksCanBrowse
|
|
5233
|
+
? el("button", { type: "button", className: styles.toggle, disabled: linksPinBusy, onClick: linksBrowse }, t("linksBrowse"))
|
|
5234
|
+
: null,
|
|
5235
|
+
el("button", {
|
|
5236
|
+
type: "button",
|
|
5237
|
+
className: styles.toggle,
|
|
5238
|
+
disabled: linksPinBusy || linksDir.trim() === "",
|
|
5239
|
+
onClick: linksPinPlanLoad,
|
|
5240
|
+
}, linksPinBusy ? t("linksPanelLoading") : t("linksPinPlanBtn"))),
|
|
5241
|
+
linksDevRootsNote === null ? null : el("p", { className: styles.status, "data-warn": "true" }, linksDevRootsNote),
|
|
5242
|
+
// 计划 / 结果卡片(两段式的第一段就在这里给人看)
|
|
5243
|
+
linksPinPlan === null
|
|
5244
|
+
? null
|
|
5245
|
+
: el("div", { style: { display: "flex", flexDirection: "column", gap: 4, fontSize: 12, padding: "6px 8px", border: "1px solid var(--dsw-alias-border-secondary, rgba(127,127,127,.18))", borderRadius: 6 } },
|
|
5246
|
+
linksPinPlan.ok !== true
|
|
5247
|
+
? el("div", { "data-warn": "true" }, t("safetySwitchFailed") + ":" + String(linksPinPlan.error ?? "-"))
|
|
5248
|
+
: (linksPinPlan.applied === true
|
|
5249
|
+
? el("div", null,
|
|
5250
|
+
t("linksPinnedDone").replace("{p}", String(linksPinPlan.plan.packageName ?? linksDir.trim()))
|
|
5251
|
+
+ (linksPinPlan.plan.after ? " → " + String(linksPinPlan.plan.after) : "")
|
|
5252
|
+
+ (linksPinPlan.plan.sufficiency && linksPinPlan.plan.sufficiency.note ? ";" + String(linksPinPlan.plan.sufficiency.note) : ""))
|
|
5253
|
+
: el("div", { style: { display: "flex", flexDirection: "column", gap: 4 } },
|
|
5254
|
+
el("div", { style: { fontWeight: 500 } }, t("linksPinPlanTitle").replace("{p}", String(linksPinPlan.plan.packageName ?? "?"))),
|
|
5255
|
+
(Array.isArray(linksPinPlan.plan.steps) ? linksPinPlan.plan.steps : []).map((step, stepIndex) => el("div", { key: "pin-step-" + stepIndex, style: { opacity: .85 } }, "· " + String(step))),
|
|
5256
|
+
el("div", { style: { display: "flex", gap: 8, marginTop: 4 } },
|
|
5257
|
+
el("button", { type: "button", className: styles.toggle, style: { fontSize: 11, padding: "2px 10px" }, disabled: linksPinBusy, onClick: linksPinConfirm }, t("linksPinConfirmBtn")),
|
|
5258
|
+
el("button", { type: "button", className: styles.toggle, style: { fontSize: 11, padding: "2px 10px" }, disabled: linksPinBusy, onClick: () => setLinksPinPlan(null) }, t("linksCancel"))))))),
|
|
5259
|
+
// ── 清单(每行按服务端结论给动作)──────────────────────────────────────
|
|
5034
5260
|
linksPanelData === null
|
|
5035
5261
|
? el("p", { className: styles.status }, t("linksPanelLoading"))
|
|
5036
5262
|
: (linksPanelData.ok === false
|
|
@@ -5045,16 +5271,40 @@ function healthSignature(health) {
|
|
|
5045
5271
|
(Array.isArray(linksPanelData.items) ? linksPanelData.items : []).length === 0
|
|
5046
5272
|
? el("p", { className: styles.status }, t("linksPanelEmpty"))
|
|
5047
5273
|
: null,
|
|
5048
|
-
(Array.isArray(linksPanelData.items) ? linksPanelData.items : []).map((x) => el("div", { key: "lp-" + x.packageName, title: x.note ?? undefined, style: { fontSize: 12, opacity: .9, wordBreak: "break-all" } },
|
|
5049
|
-
(
|
|
5050
|
-
+ (x.
|
|
5051
|
-
|
|
5052
|
-
|
|
5053
|
-
|
|
5054
|
-
|
|
5055
|
-
|
|
5274
|
+
(Array.isArray(linksPanelData.items) ? linksPanelData.items : []).map((x) => el("div", { key: "lp-" + x.packageName, title: [x.note ?? null, t("linksMtimeHint")].filter((v) => typeof v === "string" && v !== "").join("\n") || undefined, style: { fontSize: 12, opacity: .9, wordBreak: "break-all", display: "flex", flexDirection: "column", gap: 3, paddingBottom: 5 } },
|
|
5275
|
+
el("div", null,
|
|
5276
|
+
(x.ready === true ? "✓ " : "• ") + x.packageName + (x.version ? " v" + x.version : "")
|
|
5277
|
+
+ (x.target ? " → " + x.target : "")
|
|
5278
|
+
+ " · " + t("linksMtime") + " " + (typeof x.targetMtime === "string" && x.targetMtime !== "" ? formatMtimeText(x.targetMtime) : "—")
|
|
5279
|
+
+ (Array.isArray(x.missingDeps) && x.missingDeps.length > 0 ? " · 缺依赖 " + x.missingDeps.length : "")
|
|
5280
|
+
+ (Array.isArray(x.missingPeers) && x.missingPeers.length > 0 ? " · 缺 peer " + x.missingPeers.length : "")
|
|
5281
|
+
+ (x.foreignPeers !== null && x.foreignPeers !== undefined && Array.isArray(x.foreignPeers) && x.foreignPeers.length > 0 ? " · 异副本 " + x.foreignPeers.length : "")
|
|
5282
|
+
+ (x.shims !== null && x.shims !== undefined ? " · 垫片 " + String(x.shims.count ?? 0) : "")
|
|
5283
|
+
+ (x.persisted !== null && x.persisted !== undefined && x.persisted.ok === false ? " · 未持久化" : "")),
|
|
5284
|
+
// 行内动作:**有哪个显示哪个**(没有就不显示,绝不摆灰按钮)
|
|
5285
|
+
el("div", { style: { display: "flex", gap: 8, flexWrap: "wrap", alignItems: "center" } },
|
|
5286
|
+
Array.isArray(x.missingDeps) && x.missingDeps.length > 0
|
|
5287
|
+
? el("button", { type: "button", className: styles.toggle, style: { fontSize: 11, padding: "2px 8px" }, disabled: linksRowBusy !== null, onClick: () => linksRowRun(x, "deps") }, t("linksRepairDeps"))
|
|
5288
|
+
: null,
|
|
5289
|
+
Array.isArray(x.shimmablePeers) && x.shimmablePeers.length > 0
|
|
5290
|
+
? el("button", { type: "button", className: styles.toggle, style: { fontSize: 11, padding: "2px 8px" }, disabled: linksRowBusy !== null, onClick: () => linksRowRun(x, "shims") }, t("linksRepairShims"))
|
|
5291
|
+
: null,
|
|
5292
|
+
el("button", { type: "button", className: styles.toggle, style: { fontSize: 11, padding: "2px 8px" }, disabled: linksRowBusy !== null, onClick: () => linksUnpinPlanLoad(x) }, t("linksUnpinBtn")),
|
|
5293
|
+
linksRowNote[x.packageName] ? el("span", { className: styles.status }, String(linksRowNote[x.packageName])) : null),
|
|
5294
|
+
// 取消钉住的第一段:把"将会发生什么 + 后果"先摆出来(两段式,确认才动手)
|
|
5295
|
+
linksUnpinPlan !== null && linksUnpinPlan.packageName === x.packageName
|
|
5296
|
+
? el("div", { style: { display: "flex", flexDirection: "column", gap: 3, padding: "4px 6px", border: "1px solid var(--dsw-alias-border-secondary, rgba(127,127,127,.18))", borderRadius: 6 } },
|
|
5297
|
+
linksUnpinPlan.ok !== true
|
|
5298
|
+
? el("div", { "data-warn": "true" }, t("safetySwitchFailed") + ":" + String(linksUnpinPlan.error ?? "-"))
|
|
5299
|
+
: el("div", { style: { display: "flex", flexDirection: "column", gap: 3 } },
|
|
5300
|
+
(Array.isArray(linksUnpinPlan.plan.steps) ? linksUnpinPlan.plan.steps : []).map((step, unpinStepIndex) => el("div", { key: "unpin-step-" + unpinStepIndex, style: { opacity: .85 } }, "· " + String(step))),
|
|
5301
|
+
(Array.isArray(linksUnpinPlan.plan.warnings) ? linksUnpinPlan.plan.warnings : []).map((warning, warnIndex) => el("div", { key: "unpin-warn-" + warnIndex, "data-warn": "true" }, "! " + String(warning))),
|
|
5302
|
+
el("div", { style: { display: "flex", gap: 8, marginTop: 2 } },
|
|
5303
|
+
el("button", { type: "button", className: styles.toggle, style: { fontSize: 11, padding: "2px 8px" }, disabled: linksRowBusy !== null, onClick: () => linksUnpinConfirm(x) }, t("linksUnpinConfirmBtn")),
|
|
5304
|
+
el("button", { type: "button", className: styles.toggle, style: { fontSize: 11, padding: "2px 8px" }, disabled: linksRowBusy !== null, onClick: () => setLinksUnpinPlan(null) }, t("linksCancel")))))
|
|
5305
|
+
: null)))),
|
|
5056
5306
|
el("div", { className: styles.rowTop, style: { justifyContent: "flex-end", gap: 8, marginTop: 10 } },
|
|
5057
|
-
el("button", { type: "button", className: styles.toolItem, "data-busy": linksPanelBusy ? "true" : "false", onClick: linksPanelLoad }, linksPanelBusy ? t("linksPanelLoading") : t("linksPanelReload")))))
|
|
5307
|
+
el("button", { type: "button", className: styles.toolItem, "data-busy": linksPanelBusy ? "true" : "false", onClick: () => { linksPanelLoad(); linksDevRootsLoad(); } }, linksPanelBusy ? t("linksPanelLoading") : t("linksPanelReload")))))
|
|
5058
5308
|
: null,
|
|
5059
5309
|
gateOpen
|
|
5060
5310
|
? el("div", { className: styles.modalBackdrop, onClick: (event) => { if (event.target === event.currentTarget) setGateOpen(false); } },
|
|
@@ -5595,7 +5845,8 @@ function healthSignature(health) {
|
|
|
5595
5845
|
title: t("linksPanelTitle"),
|
|
5596
5846
|
"aria-label": t("linksBtn"),
|
|
5597
5847
|
// ★ 0.5.84(用户第 6 项):打开时拉一次真实清单(与安全面板同一个只读接口)
|
|
5598
|
-
|
|
5848
|
+
// ★ 0.5.87:同时拉一次「已登记开发区」(钉住要用;没有它下拉就是空的)
|
|
5849
|
+
onClick: () => setLinksPanelOpen((v) => { const next = !v; if (next) { linksPanelLoad(); linksDevRootsLoad(); } return next; }),
|
|
5599
5850
|
}, t("linksBtn") + (linksPanelData !== null && Array.isArray(linksPanelData.items) ? " " + linksPanelData.items.length : "")),
|
|
5600
5851
|
el("button", {
|
|
5601
5852
|
type: "button",
|
|
@@ -6766,6 +7017,10 @@ function healthSignature(health) {
|
|
|
6766
7017
|
} catch {}
|
|
6767
7018
|
}
|
|
6768
7019
|
function apply(ctx) {
|
|
7020
|
+
// ★ 0.5.87:探测宿主有没有系统目录对话框(桌面端外壳有、web 端没有)。
|
|
7021
|
+
// 主面板据此决定**是否渲染**「浏览…」—— 没有就根本不存在这个按钮,
|
|
7022
|
+
// 而不是摆一个点了没反应的(用户 2026-10-18 明确要求)。
|
|
7023
|
+
directoryPicker = detectDirectoryPicker();
|
|
6769
7024
|
ctx.effect(() => ctx.locale.register(NS, { zh, en }), "plugin-console: dictionaries");
|
|
6770
7025
|
const t = ctx.locale.bind(NS);
|
|
6771
7026
|
ctx.slots.inject("settings.plugins.tab", () => ctx.slots.register({
|
|
@@ -6809,6 +7064,11 @@ function healthSignature(health) {
|
|
|
6809
7064
|
// 仅测试用导出(tests/test-adapt-unlock-gate.mjs ①-2:断言「已适配,立即解锁」按钮的门控判据,
|
|
6810
7065
|
// 以及它必须与卡片正文用同一个判据)。
|
|
6811
7066
|
exports.canAdaptUnlock = canAdaptUnlock;
|
|
7067
|
+
// 仅测试用导出(tests/test-links-actions-ui.mjs:假 React **真渲染**主面板,断言 link 面板
|
|
7068
|
+
// 的按钮按服务端结论出现/不出现、「浏览…」只在宿主提供目录对话框时存在、钉住是两段式)。
|
|
7069
|
+
exports.PluginConsoleTab = PluginConsoleTab;
|
|
7070
|
+
// 仅测试用导出(同上:断言「宿主有没有系统目录对话框」这条探测本身)。
|
|
7071
|
+
exports.detectDirectoryPicker = detectDirectoryPicker;
|
|
6812
7072
|
return module.exports;
|
|
6813
7073
|
}
|
|
6814
7074
|
});
|
|
@@ -103,6 +103,18 @@ function checkInsideDevRoots(home, candidate) {
|
|
|
103
103
|
const rootNorm = normalizePath(root)
|
|
104
104
|
if (isInside(norm, rootNorm)) return { ok: true, reason: null, normalized: norm, root }
|
|
105
105
|
}
|
|
106
|
+
// 2026-10-18 改错(0.5.87):**它就是某个已注册的根本身**时,旧文案说"不在任何已注册的开发区根里"
|
|
107
|
+
// —— 而它恰恰就在里面(只是"根"不算"根之内",这条边界是刻意的、不改)。那句话会把人引向错误的
|
|
108
|
+
// 下一步(去注册一个已经注册过的根),所以这里单列一句准确的话:要钉的是**根里的那个插件目录**。
|
|
109
|
+
const rootNormItself = roots.map((root) => normalizePath(root)).find((rootNorm) => rootNorm === norm) ?? null
|
|
110
|
+
if (rootNormItself !== null) {
|
|
111
|
+
return {
|
|
112
|
+
ok: false,
|
|
113
|
+
reason: `这是开发区根本身,不是根里的插件目录:${candidate}\n请指向根里的那个目录(例如 ${join(candidate, '<插件目录名>')})——「根」不能当依赖目标,这是刻意的边界`,
|
|
114
|
+
normalized: norm,
|
|
115
|
+
root: null,
|
|
116
|
+
}
|
|
117
|
+
}
|
|
106
118
|
return {
|
|
107
119
|
ok: false,
|
|
108
120
|
reason: `不在任何已注册的开发区根里:${candidate}\n已注册:${roots.join(' / ')}\n(这是刻意的限制:只允许钉住开发区里的目录,不允许任意路径)`,
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// L1 · domain —— install-box.js(「盒子实验」:解压后的包目录先静态校验再覆盖正式位置)
|
|
2
|
+
//
|
|
3
|
+
// 2026-10-18 从 domain/install.js **原样搬出**(install.js 贴着 600 行架构硬顶,见
|
|
4
|
+
// tests/test-architecture-guard.mjs;这一块本来就是自洽的"装前只读校验",与 sources 无关)。
|
|
5
|
+
// 搬移只搬移未改逻辑:install.js 继续 re-export 这两个名字,**对外导出名与调用点一个字都没变**。
|
|
6
|
+
//
|
|
7
|
+
// 校验什么(都是"装了也起不来"的硬伤,所以要在覆盖旧版本**之前**拦住):
|
|
8
|
+
// · package.json 必须可解析且 name 与安装目标一致;
|
|
9
|
+
// · main / exports 入口文件必须真实存在;
|
|
10
|
+
// · bundle patch(cordis.patch.yml)引用的包必须已就位(防聚合包引用缺失崩溃)。
|
|
11
|
+
// 失败抛错 → 调用方保留旧版本(安装不中断服务)。
|
|
12
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
13
|
+
import { join } from 'node:path'
|
|
14
|
+
|
|
15
|
+
/** 盒子验证:解压后的包目录先通过静态校验再允许覆盖正式位置。
|
|
16
|
+
* refCheckRoot 指定引用检查根(宿主插件在根层 node_modules,与 profile 层不同)。 */
|
|
17
|
+
function verifyPackageBox(pkgPath, profileDir, packageName, refCheckRoot = null) {
|
|
18
|
+
const note = []
|
|
19
|
+
const pkgRaw = readFileSync(join(pkgPath, 'package.json'), 'utf8')
|
|
20
|
+
const pkg = JSON.parse(pkgRaw)
|
|
21
|
+
if (pkg.name !== packageName) {
|
|
22
|
+
throw new Error(`盒子验证失败:包名不符(tarball 内为 ${pkg.name},期望 ${packageName}),已保留旧版本`)
|
|
23
|
+
}
|
|
24
|
+
// 入口存在性:main 字段 / exports.'.'(字符串或对象 default)指向的文件必须存在
|
|
25
|
+
let entry = typeof pkg.main === 'string' ? pkg.main : null
|
|
26
|
+
if (entry === null && pkg.exports && typeof pkg.exports === 'object') {
|
|
27
|
+
const dot = pkg.exports['.'] ?? pkg.exports['./package.json'] === undefined ? pkg.exports['.'] : null
|
|
28
|
+
if (typeof dot === 'string') entry = dot
|
|
29
|
+
else if (dot && typeof dot === 'object') entry = typeof dot.default === 'string' ? dot.default : null
|
|
30
|
+
}
|
|
31
|
+
if (entry !== null) {
|
|
32
|
+
const entryPath = join(pkgPath, ...entry.split('/'))
|
|
33
|
+
if (!existsSync(entryPath)) {
|
|
34
|
+
throw new Error(`盒子验证失败:入口文件缺失(${entry}),已保留旧版本`)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
// bundle patch 引用预检:引用的包必须已存在于目标 node_modules(防聚合包半更新崩溃)。
|
|
38
|
+
// refCheckRoot 指定检查根(宿主插件在根层 node_modules,与 profile 层不同)。
|
|
39
|
+
const checkRoot = refCheckRoot ?? join(profileDir, 'node_modules')
|
|
40
|
+
try {
|
|
41
|
+
const patchRel = pkg.dsh?.bundle?.patch
|
|
42
|
+
if (typeof patchRel === 'string') {
|
|
43
|
+
const bundlePatch = join(pkgPath, patchRel)
|
|
44
|
+
if (existsSync(bundlePatch)) {
|
|
45
|
+
const refs = parseBundlePatchRefs(readFileSync(bundlePatch, 'utf8'))
|
|
46
|
+
const missingRefs = refs.filter((r) => !existsSync(join(checkRoot, r.name)))
|
|
47
|
+
if (missingRefs.length > 0) {
|
|
48
|
+
note.push(`bundle 引用缺失:${missingRefs.map((r) => r.name).join('、')}(安装后由聚合完整性检查补齐/禁用)`)
|
|
49
|
+
}
|
|
50
|
+
if (refs.length > 0) note.push(`bundle 引用 ${refs.length} 个已就位`)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if (error instanceof Error && error.message.startsWith('盒子验证失败')) throw error
|
|
55
|
+
}
|
|
56
|
+
return { note: note.length > 0 ? note.join(';') : null }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 解析 bundle patch(cordis.patch.yml)的 insert 引用列表(id + 包名)。 */
|
|
60
|
+
function parseBundlePatchRefs(text) {
|
|
61
|
+
const refs = []
|
|
62
|
+
const lines = text.split(/\r?\n/u)
|
|
63
|
+
let inInsert = false
|
|
64
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
65
|
+
const line = lines[i]
|
|
66
|
+
if (/^- insert:\s*$/u.test(line)) { inInsert = true; continue }
|
|
67
|
+
if (/^- /u.test(line) && !/^ {4}- /u.test(line)) inInsert = false
|
|
68
|
+
if (!inInsert) continue
|
|
69
|
+
const idM = line.match(/^ {4}- id: ([A-Za-z0-9_.-]+)\s*$/u)
|
|
70
|
+
if (!idM) continue
|
|
71
|
+
const nameM = (lines[i + 1] ?? '').match(/^ {6}name: ['"]([^'"]+)['"]\s*$/u)
|
|
72
|
+
if (nameM) refs.push({ id: idM[1], name: nameM[1] })
|
|
73
|
+
}
|
|
74
|
+
return refs
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export { verifyPackageBox, parseBundlePatchRefs }
|