@noob-stupid/dsh-plugin-console 0.5.55 → 0.5.57
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 +111 -1
- package/lib/server/domain/compat-exempt.js +127 -0
- package/lib/server/domain/dev-roots.js +147 -0
- package/lib/server/domain/pin-dev-dir.js +150 -0
- package/lib/server/routes/index.js +6 -1
- package/lib/server/routes/state.js +106 -1
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -121,6 +121,20 @@ window.__ModuleLoader__.load({
|
|
|
121
121
|
hostedNoChannel: "本实例由桌面端外壳托管:框架升级与回滚请走桌面端(此处不可用)",
|
|
122
122
|
linksTitle: "开发式链接(link):共 {n} 个,自足 {ready} 个 —— ✓ 自足 · · 缺东西(悬停看原因)",
|
|
123
123
|
healthSummary: "需要处理 {n} 类问题",
|
|
124
|
+
exemptGrantBtn: "授精确版本豁免",
|
|
125
|
+
devRootsTitle: "开发区根(只有这些根里的目录能钉住)",
|
|
126
|
+
devRootsNone: "(未登记)",
|
|
127
|
+
devRootAddPlaceholder: "注册新的开发区根(绝对路径,例如 D:\\my-plugins)",
|
|
128
|
+
devRootAddBtn: "注册根",
|
|
129
|
+
devRootAdded: "已注册(列表已刷新)",
|
|
130
|
+
pinDirPlaceholder: "要钉住的目录(必须在上面某个根里)",
|
|
131
|
+
pinPlanBtn: "先看会发生什么",
|
|
132
|
+
pinApplyBtn: "确认钉住",
|
|
133
|
+
pinAllowAgent: "允许 agent 自动钉住",
|
|
134
|
+
pinAllowAgentTitle: "默认关闭:勾选后,agent 发起的钉住才会被服务端接受",
|
|
135
|
+
pinPlanReady: "将把 {name} v{v} 钉成 link 依赖",
|
|
136
|
+
pinDone: "已钉住 {name}(清单已备份;若 pnpm 或验证失败已自动回滚)",
|
|
137
|
+
exemptGrantTitle: "把「这个精确版本」在「当前框架版本」上授权给框架(写入 profile 的 compatibility.json,先备份、写完读回验证)",
|
|
124
138
|
linksFailed: "开发式链接清单读取失败",
|
|
125
139
|
actionReconcileLabel: "重建 pnpm-lock.yaml",
|
|
126
140
|
actionRun: "执行",
|
|
@@ -715,6 +729,20 @@ window.__ModuleLoader__.load({
|
|
|
715
729
|
hostedNoChannel: "Hosted by the desktop shell: use the desktop app for framework upgrade and rollback (unavailable here)",
|
|
716
730
|
linksTitle: "Dev links (link:): {n} total, {ready} self-sufficient - checkmark = ready, dot = something missing (hover for why)",
|
|
717
731
|
healthSummary: "{n} issue type(s) need attention",
|
|
732
|
+
exemptGrantBtn: "Grant exact-version exemption",
|
|
733
|
+
devRootsTitle: "Dev roots (only directories inside these can be pinned)",
|
|
734
|
+
devRootsNone: "(none registered)",
|
|
735
|
+
devRootAddPlaceholder: "Register a dev root (absolute path, e.g. D:\\my-plugins)",
|
|
736
|
+
devRootAddBtn: "Add root",
|
|
737
|
+
devRootAdded: "Registered (list refreshed)",
|
|
738
|
+
pinDirPlaceholder: "Directory to pin (must be inside one of the roots above)",
|
|
739
|
+
pinPlanBtn: "Preview",
|
|
740
|
+
pinApplyBtn: "Pin it",
|
|
741
|
+
pinAllowAgent: "Allow agent-initiated pins",
|
|
742
|
+
pinAllowAgentTitle: "Off by default: once ticked, pins initiated by the agent are accepted",
|
|
743
|
+
pinPlanReady: "Will pin {name} v{v} as a link dependency",
|
|
744
|
+
pinDone: "Pinned {name} (manifest backed up; rolled back automatically if pnpm or verification failed)",
|
|
745
|
+
exemptGrantTitle: "Authorize this exact version on the current framework version (writes via compatibility.json in the profile, with backup and read-back verification)",
|
|
718
746
|
linksFailed: "Failed to read the dev-link inventory",
|
|
719
747
|
actionReconcileLabel: "Rebuild pnpm-lock.yaml",
|
|
720
748
|
actionRun: "Run",
|
|
@@ -4610,7 +4638,21 @@ window.__ModuleLoader__.load({
|
|
|
4610
4638
|
el("p", null,
|
|
4611
4639
|
t("skippedBundlesTitle").replace("{n}", String(state.data.skippedBundles.items.length))),
|
|
4612
4640
|
...state.data.skippedBundles.items.map((b) => el("p", { key: b.packageName, title: b.hint ?? undefined, style: { margin: "4px 0 0 12px" } },
|
|
4613
|
-
"• " + b.packageName + (b.version ? " v" + b.version : "") + " — " + b.reason
|
|
4641
|
+
"• " + b.packageName + (b.version ? " v" + b.version : "") + " — " + b.reason,
|
|
4642
|
+
// ★ 0.5.56:把提示里的"三条出路"之一真正做成可点的动作。
|
|
4643
|
+
// 过去控制台**只读** compatibility.json、从不写 ⇒ 那句提示等于把活推回用户。
|
|
4644
|
+
// 只在能确定精确版本时才给按钮(写豁免必须按精确版本,否则框架会忽略)。
|
|
4645
|
+
b.version
|
|
4646
|
+
? el("button", {
|
|
4647
|
+
type: "button",
|
|
4648
|
+
style: { marginLeft: "8px", cursor: "pointer" },
|
|
4649
|
+
title: t("exemptGrantTitle"),
|
|
4650
|
+
onClick: () => {
|
|
4651
|
+
call("/plugin-console/compat-exempt", { packageName: b.packageName, version: b.version })
|
|
4652
|
+
.then(() => refresh(), () => {});
|
|
4653
|
+
},
|
|
4654
|
+
}, t("exemptGrantBtn"))
|
|
4655
|
+
: null)),
|
|
4614
4656
|
)
|
|
4615
4657
|
: null,
|
|
4616
4658
|
|
|
@@ -6124,10 +6166,17 @@ window.__ModuleLoader__.load({
|
|
|
6124
6166
|
// 过去 link 钉住的插件与普通插件长得一模一样 —— 看不出指向哪、目标还在不在、
|
|
6125
6167
|
// 依赖/peer 齐不齐、垫片还在不在。这份清单让"看见"先成立。
|
|
6126
6168
|
const [links, setLinks] = react.useState(null);
|
|
6169
|
+
// ★ 0.5.57:开发区根 + 钉住输入(用户决定:允许注册自己的根;不允许任意目录;agent 钉住需勾选、默认关)
|
|
6170
|
+
const [devRoots, setDevRoots] = react.useState([]);
|
|
6171
|
+
const [rootInput, setRootInput] = react.useState("");
|
|
6172
|
+
const [pinInput, setPinInput] = react.useState("");
|
|
6173
|
+
const [pinNote, setPinNote] = react.useState(null);
|
|
6174
|
+
const [allowAgent, setAllowAgent] = react.useState(false);
|
|
6127
6175
|
const load = async () => {
|
|
6128
6176
|
try {
|
|
6129
6177
|
// link 清单:独立 try(拿不到时这块显示"读取失败",绝不静默)
|
|
6130
6178
|
try { const lk = await call("/plugin-console/links"); if (lk !== null) setLinks(lk); } catch {}
|
|
6179
|
+
try { const dr = await call("/plugin-console/dev-roots", { action: "list" }); if (dr !== null && Array.isArray(dr.roots)) setDevRoots(dr.roots); } catch {}
|
|
6131
6180
|
const data = await call("/plugin-console/compat-status");
|
|
6132
6181
|
if (data !== null) {
|
|
6133
6182
|
setStatus(data);
|
|
@@ -6279,9 +6328,70 @@ window.__ModuleLoader__.load({
|
|
|
6279
6328
|
+ (Array.isArray(x.foreignPeers) && x.foreignPeers.length > 0 ? " · 异副本 " + x.foreignPeers.length : "")
|
|
6280
6329
|
+ (x.shims !== null && x.shims !== undefined ? " · 垫片 " + String(x.shims.count ?? 0) : "")
|
|
6281
6330
|
+ (x.persisted !== null && x.persisted !== undefined && x.persisted.ok === false ? " · 未持久化" : ""))))
|
|
6331
|
+
|
|
6282
6332
|
: (links !== null && links.ok === false
|
|
6283
6333
|
? el("div", { style: { fontSize: "12px", marginTop: "4px", opacity: .8 } }, t("linksFailed") + ":" + String(links.error ?? "-"))
|
|
6284
6334
|
: null),
|
|
6335
|
+
// ★ 0.5.57:开发区根与"按路径钉住" —— 把协作开发流真正接通。
|
|
6336
|
+
// 规矩全部由服务端执行(domain/dev-roots.js 是"不许任意目录"的唯一执行点),
|
|
6337
|
+
// 这里只负责:先规划给人看 → 再让人确认 → 才真写。
|
|
6338
|
+
el("div", { style: { display: "flex", flexDirection: "column", gap: "4px", marginTop: "6px", paddingTop: "6px", borderTop: "1px solid var(--dsw-alias-border-secondary, rgba(127,127,127,.18))" } },
|
|
6339
|
+
el("div", { style: { fontSize: "12px", fontWeight: 500 } }, t("devRootsTitle")),
|
|
6340
|
+
el("div", { style: { fontSize: "12px", opacity: .8, marginLeft: "10px", wordBreak: "break-all" } }, devRoots.length === 0 ? t("devRootsNone") : devRoots.join(" · ")),
|
|
6341
|
+
el("div", { style: { display: "flex", gap: "6px", marginLeft: "10px", flexWrap: "wrap" } },
|
|
6342
|
+
el("input", {
|
|
6343
|
+
type: "text", value: rootInput, placeholder: t("devRootAddPlaceholder"),
|
|
6344
|
+
style: { flex: "1 1 240px", minWidth: "180px", fontSize: "12px", padding: "3px 6px" },
|
|
6345
|
+
onChange: (e) => setRootInput(e.target.value),
|
|
6346
|
+
}),
|
|
6347
|
+
el("button", {
|
|
6348
|
+
type: "button", className: styles.toggle, disabled: busy || rootInput.trim() === "",
|
|
6349
|
+
onClick: async () => {
|
|
6350
|
+
setBusy(true); setPinNote(null);
|
|
6351
|
+
try {
|
|
6352
|
+
const r = await call("/plugin-console/dev-roots", { action: "add", dir: rootInput.trim() });
|
|
6353
|
+
if (r !== null) { setRootInput(""); setPinNote({ text: t("devRootAdded"), level: null }); await load(); }
|
|
6354
|
+
} catch (error) { setPinNote({ text: String(error && error.message || error), level: "error" }); }
|
|
6355
|
+
setBusy(false);
|
|
6356
|
+
},
|
|
6357
|
+
}, t("devRootAddBtn"))),
|
|
6358
|
+
el("div", { style: { display: "flex", gap: "6px", marginLeft: "10px", flexWrap: "wrap" } },
|
|
6359
|
+
el("input", {
|
|
6360
|
+
type: "text", value: pinInput, placeholder: t("pinDirPlaceholder"),
|
|
6361
|
+
style: { flex: "1 1 280px", minWidth: "200px", fontSize: "12px", padding: "3px 6px" },
|
|
6362
|
+
onChange: (e) => setPinInput(e.target.value),
|
|
6363
|
+
}),
|
|
6364
|
+
// ③ 用户决定:agent 自动钉住要勾选、**默认关闭**
|
|
6365
|
+
el("label", { style: { fontSize: "12px", display: "flex", alignItems: "center", gap: "4px" }, title: t("pinAllowAgentTitle") },
|
|
6366
|
+
el("input", { type: "checkbox", checked: allowAgent, onChange: (e) => setAllowAgent(e.target.checked) }),
|
|
6367
|
+
t("pinAllowAgent")),
|
|
6368
|
+
el("button", {
|
|
6369
|
+
type: "button", className: styles.toggle, disabled: busy || pinInput.trim() === "",
|
|
6370
|
+
onClick: async () => {
|
|
6371
|
+
setBusy(true); setPinNote(null);
|
|
6372
|
+
try {
|
|
6373
|
+
// 第一段:只规划,把"将要发生什么"给人看(人性化:不让人闭眼点)
|
|
6374
|
+
const plan = await call("/plugin-console/pin-dev-dir", { dir: pinInput.trim(), confirm: false, allowAgent });
|
|
6375
|
+
if (plan !== null && plan.ok === true) {
|
|
6376
|
+
setPinNote({ text: fill(t("pinPlanReady"), [["name", String(plan.packageName)], ["v", String(plan.version ?? "?")]]) + " → " + (plan.steps || []).join(";"), level: null });
|
|
6377
|
+
}
|
|
6378
|
+
} catch (error) { setPinNote({ text: String(error && error.message || error), level: "error" }); }
|
|
6379
|
+
setBusy(false);
|
|
6380
|
+
},
|
|
6381
|
+
}, t("pinPlanBtn")),
|
|
6382
|
+
el("button", {
|
|
6383
|
+
type: "button", className: styles.toggle, disabled: busy || pinInput.trim() === "",
|
|
6384
|
+
onClick: async () => {
|
|
6385
|
+
setBusy(true); setPinNote(null);
|
|
6386
|
+
try {
|
|
6387
|
+
// 第二段:真写(备份清单 → link: → pnpm 对齐 → 自足性 → 读回验证)
|
|
6388
|
+
const r = await call("/plugin-console/pin-dev-dir", { dir: pinInput.trim(), confirm: true, allowAgent });
|
|
6389
|
+
if (r !== null) { setPinNote({ text: fill(t("pinDone"), [["name", String(r.packageName ?? pinInput.trim())]]), level: null }); setPinInput(""); await load(); }
|
|
6390
|
+
} catch (error) { setPinNote({ text: String(error && error.message || error), level: "error" }); }
|
|
6391
|
+
setBusy(false);
|
|
6392
|
+
},
|
|
6393
|
+
}, t("pinApplyBtn"))),
|
|
6394
|
+
pinNote === null ? null : el("div", { style: { fontSize: "12px", marginLeft: "10px", color: pinNote.level === "error" ? "#d33" : "inherit", wordBreak: "break-word" } }, pinNote.text)),
|
|
6285
6395
|
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),
|
|
6286
6396
|
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),
|
|
6287
6397
|
// 依赖锁体检 / 重建(2026-09-27 加法):体检只读;重建必须用户点(服务端也只认显式 POST)。
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// L1 · domain —— 兼容豁免的**写入**(0.5.56 加法:把"只读"补成"可操作")
|
|
2
|
+
//
|
|
3
|
+
// 为什么需要:
|
|
4
|
+
// 0.5.53 让"被框架跳过的 bundle"可见了,提示里写着三条出路(更新 / 授精确版本豁免 / 从 bundles 删掉),
|
|
5
|
+
// 但控制台**只会读** `compatibility.json`,从不会写 —— 于是那句提示等于把活推回给用户:
|
|
6
|
+
// "你自己去编辑那个文件,格式自己查"。这就是半截活。
|
|
7
|
+
//
|
|
8
|
+
// 格式(与框架 `readProfileCompatibility` 逐字对齐,读错会静默失效):
|
|
9
|
+
// {
|
|
10
|
+
// "@scope/pkg@1.2.3": ["0.2.1-rc.2", "0.2.1-rc.1"] // 精确包版本 → 允许的精确 DSH 版本列表
|
|
11
|
+
// }
|
|
12
|
+
// · 键必须是**精确** `name@version`(框架用 isExactPluginVersion 校验,范围/前缀一律被忽略)
|
|
13
|
+
// · 值必须是精确版本字符串数组
|
|
14
|
+
// · 坏记录被框架**忽略**(不是致命)——所以写入方必须自己保证格式,否则"写了但没生效"且没人知道
|
|
15
|
+
//
|
|
16
|
+
// 纪律:
|
|
17
|
+
// · **绝不静默**:每一次写盘都先备份、写完**读回来验证**,验证不过就回滚并如实报错;
|
|
18
|
+
// · 只增删**指定那一条**,其余记录(含用户手写的)原样保留、键序不变;
|
|
19
|
+
// · 撤销 = 从该键的列表里去掉当前框架版本;列表空了才删键(不误伤同一包的其它授权)。
|
|
20
|
+
//
|
|
21
|
+
// 分层:L1 domain —— 收已解开的 profileDir,不认 ctx;不联网、不 spawn。
|
|
22
|
+
import { copyFileSync, existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
23
|
+
import { join } from 'node:path'
|
|
24
|
+
import { COMPATIBILITY_FILE, readCompatExemptions } from './bundle-veto.js'
|
|
25
|
+
|
|
26
|
+
const readJsonSafe = (file) => {
|
|
27
|
+
try { return JSON.parse(readFileSync(file, 'utf8')) } catch { return null }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 精确版本判据(框架同义:不接受范围/前缀/空白)。 */
|
|
31
|
+
const EXACT_VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/u
|
|
32
|
+
/** 精确 `name@version` 键。 */
|
|
33
|
+
function isExactPackageVersionKey(key) {
|
|
34
|
+
if (typeof key !== 'string') return false
|
|
35
|
+
const at = key.lastIndexOf('@')
|
|
36
|
+
if (at <= 0) return false
|
|
37
|
+
return EXACT_VERSION_RE.test(key.slice(at + 1)) && /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/u.test(key.slice(0, at))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 规划一次豁免写入(**纯函数,不写盘**)。
|
|
42
|
+
*
|
|
43
|
+
* @param {object} p
|
|
44
|
+
* @param {string} p.profileDir profile 根目录
|
|
45
|
+
* @param {string} p.packageName 包名
|
|
46
|
+
* @param {string} p.version 该包的**精确**版本
|
|
47
|
+
* @param {string} p.frameworkVersion 要授权的**精确** DSH 版本
|
|
48
|
+
* @param {'grant'|'revoke'} [p.mode] grant=加一条授权(默认),revoke=撤销当前框架版本的授权
|
|
49
|
+
* @returns {{ ok: boolean, status: number, error: string|null, file: string, next: object|null,
|
|
50
|
+
* changed: boolean, key: string|null, note: string|null }}
|
|
51
|
+
*/
|
|
52
|
+
function planExemption({ profileDir, packageName, version, frameworkVersion, mode = 'grant' } = {}) {
|
|
53
|
+
const file = typeof profileDir === 'string' && profileDir !== '' ? join(profileDir, COMPATIBILITY_FILE) : null
|
|
54
|
+
const base = { ok: false, status: 400, error: null, file, next: null, changed: false, key: null, note: null }
|
|
55
|
+
if (file === null) return { ...base, error: '拿不到 profile 目录,拒绝改动' }
|
|
56
|
+
if (typeof packageName !== 'string' || packageName === '') return { ...base, error: '缺少 packageName' }
|
|
57
|
+
if (typeof version !== 'string' || !EXACT_VERSION_RE.test(version)) {
|
|
58
|
+
return { ...base, error: `version 必须是精确版本(收到 ${JSON.stringify(version)});豁免按精确版本生效,范围没有意义` }
|
|
59
|
+
}
|
|
60
|
+
if (typeof frameworkVersion !== 'string' || !EXACT_VERSION_RE.test(frameworkVersion)) {
|
|
61
|
+
return { ...base, error: `拿不到精确的框架版本(收到 ${JSON.stringify(frameworkVersion)})—— 拒绝写一条"授权了但不知道授权给谁"的记录` }
|
|
62
|
+
}
|
|
63
|
+
const key = `${packageName}@${version}`
|
|
64
|
+
if (!isExactPackageVersionKey(key)) return { ...base, error: `键 ${key} 不是合法的精确 name@version` }
|
|
65
|
+
|
|
66
|
+
// 现有文件:坏文件不覆盖(那是用户的文件,覆盖等于丢数据)
|
|
67
|
+
let current = {}
|
|
68
|
+
if (existsSync(file)) {
|
|
69
|
+
const parsed = readJsonSafe(file)
|
|
70
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
71
|
+
return { ...base, status: 409, error: `${file} 已存在但不是合法对象;为避免覆盖你的文件,本动作已停止(请先手工修好或删掉它)` }
|
|
72
|
+
}
|
|
73
|
+
current = parsed
|
|
74
|
+
}
|
|
75
|
+
const next = { ...current }
|
|
76
|
+
const existing = Array.isArray(next[key]) ? next[key].filter((v) => typeof v === 'string') : []
|
|
77
|
+
|
|
78
|
+
if (mode === 'revoke') {
|
|
79
|
+
if (!existing.includes(frameworkVersion)) {
|
|
80
|
+
return { ...base, ok: true, status: 200, next: current, changed: false, key, note: `没有 ${key} → ${frameworkVersion} 的授权,无需撤销` }
|
|
81
|
+
}
|
|
82
|
+
const left = existing.filter((v) => v !== frameworkVersion)
|
|
83
|
+
if (left.length === 0) delete next[key]
|
|
84
|
+
else next[key] = left
|
|
85
|
+
return { ...base, ok: true, status: 200, next, changed: true, key, note: `已撤销 ${key} 对 ${frameworkVersion} 的授权` }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (existing.includes(frameworkVersion)) {
|
|
89
|
+
return { ...base, ok: true, status: 200, next: current, changed: false, key, note: `${key} 已授权 ${frameworkVersion},无需重复写入` }
|
|
90
|
+
}
|
|
91
|
+
next[key] = [...existing, frameworkVersion]
|
|
92
|
+
return { ...base, ok: true, status: 200, next, changed: true, key, note: `已授权 ${key} 在 ${frameworkVersion} 上使用` }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 执行写入:备份 → 原子写(临时文件 + rename)→ **读回来验证** → 失败则回滚。
|
|
97
|
+
* 绝不谎报成功:验证不过一律返回 ok:false 并把原因写清。
|
|
98
|
+
*/
|
|
99
|
+
function applyExemption(input) {
|
|
100
|
+
const plan = planExemption(input)
|
|
101
|
+
if (plan.ok !== true || plan.changed !== true) return plan
|
|
102
|
+
const { file, next, key } = plan
|
|
103
|
+
const backup = `${file}.bak-${Date.now()}`
|
|
104
|
+
try {
|
|
105
|
+
if (existsSync(file)) copyFileSync(file, backup)
|
|
106
|
+
const tmp = `${file}.tmp-${Date.now()}`
|
|
107
|
+
writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`, 'utf8')
|
|
108
|
+
renameSync(tmp, file)
|
|
109
|
+
} catch (error) {
|
|
110
|
+
// 写失败:把备份放回去(有备份时),如实报
|
|
111
|
+
try { if (existsSync(backup)) copyFileSync(backup, file) } catch {}
|
|
112
|
+
return { ...plan, ok: false, status: 500, error: `写入 ${file} 失败:${error instanceof Error ? error.message : String(error)}`, backup }
|
|
113
|
+
}
|
|
114
|
+
// 读回来验证:框架读得出来的才算真的生效("写了但没生效"是最坏的一种假绿)
|
|
115
|
+
const verify = readCompatExemptions(typeof input?.profileDir === 'string' ? input.profileDir : '')
|
|
116
|
+
const granted = Array.isArray(verify[key]) ? verify[key] : []
|
|
117
|
+
const want = input?.mode === 'revoke'
|
|
118
|
+
? !granted.includes(input?.frameworkVersion)
|
|
119
|
+
: granted.includes(input?.frameworkVersion)
|
|
120
|
+
if (!want) {
|
|
121
|
+
try { if (existsSync(backup)) copyFileSync(backup, file) } catch {}
|
|
122
|
+
return { ...plan, ok: false, status: 500, error: `写入后验证失败(${file} 读回来不符合预期),已回滚`, backup }
|
|
123
|
+
}
|
|
124
|
+
return { ...plan, ok: true, backup }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export { EXACT_VERSION_RE, isExactPackageVersionKey, planExemption, applyExemption }
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// L1 · domain —— 开发区根(dev roots):**"钉住任意目录"的安全边界**(0.5.57 加法)
|
|
2
|
+
//
|
|
3
|
+
// 用户 2026-10-15 的三个决定(原话):
|
|
4
|
+
// ① 「允许」—— 允许注册自己的开发区根(不只 `~/.dsh/plugin-src`);
|
|
5
|
+
// ② 「不允许」—— **不允许任意目录**:目标必须落在某个已注册的根里;
|
|
6
|
+
// ③ 「要,默认关闭」—— agent 自动钉住必须由用户勾选,且默认关闭。
|
|
7
|
+
//
|
|
8
|
+
// 本模块只负责 ①②:登记表 + **包含性判定**。③ 属于动作层的开关,见 domain/pin-dev-dir.js。
|
|
9
|
+
//
|
|
10
|
+
// 为什么包含性判定必须写在这里、且写得死板:
|
|
11
|
+
// 它是"不许任意目录"这条决定的**唯一执行点**。写松了(比如只用 startsWith 字符串前缀),
|
|
12
|
+
// `C:\dev` 会命中 `C:\devil\evil`;用 realpath 才能挡掉"根里放一个指向别处的 junction"。
|
|
13
|
+
// 三条都做到才算数:**realpath 归一 → Windows 大小写不敏感 → 路径分隔符边界**。
|
|
14
|
+
//
|
|
15
|
+
// 文件:`<DSH_HOME>/plugin-console/dev-roots.json`
|
|
16
|
+
// { "roots": ["C:\\Users\\x\\.dsh\\plugin-src", "D:\\my-dev"] }
|
|
17
|
+
// 缺省 = 只有 `<DSH_HOME>/plugin-src`(用户现有 7 个 link 里 6 个就在这)。
|
|
18
|
+
//
|
|
19
|
+
// 分层:L1 domain —— 收 home(已解开的字符串),不认 ctx;不联网、不 spawn。
|
|
20
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync } from 'node:fs'
|
|
21
|
+
import { homedir } from 'node:os'
|
|
22
|
+
import { dirname, join, resolve, sep } from 'node:path'
|
|
23
|
+
|
|
24
|
+
const DEV_ROOTS_FILE = 'dev-roots.json'
|
|
25
|
+
|
|
26
|
+
/** DSH 家目录(与其它模块同口径:优先 env,否则 ~/.dsh)。 */
|
|
27
|
+
function homeDirOf(home) {
|
|
28
|
+
if (typeof home === 'string' && home !== '') return home
|
|
29
|
+
const env = process.env.DSH_HOME
|
|
30
|
+
return typeof env === 'string' && env !== '' ? env : join(homedir(), '.dsh')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 登记表文件路径。 */
|
|
34
|
+
function devRootsFile(home) {
|
|
35
|
+
return join(homeDirOf(home), 'plugin-console', DEV_ROOTS_FILE)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 默认根:`<DSH_HOME>/plugin-src`(用户现有 link 目标绝大多数在这里)。 */
|
|
39
|
+
function defaultDevRoot(home) {
|
|
40
|
+
return join(homeDirOf(home), 'plugin-src')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 归一化:realpath(存在时)→ 去尾分隔符 → Windows 大小写折叠。判等/包含都走它。 */
|
|
44
|
+
function normalizePath(p) {
|
|
45
|
+
if (typeof p !== 'string' || p.trim() === '') return null
|
|
46
|
+
let out = resolve(p)
|
|
47
|
+
try { if (existsSync(out)) out = realpathSync(out) } catch {}
|
|
48
|
+
out = out.replace(/[\\/]+$/u, '')
|
|
49
|
+
return process.platform === 'win32' ? out.toLowerCase() : out
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** child 是否**严格位于** root 之内(不含 root 本身)。分隔符边界必须成立。 */
|
|
53
|
+
function isInside(childNorm, rootNorm) {
|
|
54
|
+
if (childNorm === null || rootNorm === null) return false
|
|
55
|
+
if (childNorm === rootNorm) return false
|
|
56
|
+
return childNorm.startsWith(rootNorm.endsWith(sep) ? rootNorm : rootNorm + sep)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 读登记表。**缺省即默认根**;坏文件降级为默认根(并如实带 warnings,不假装一切正常)。
|
|
61
|
+
* @returns {{ roots: string[], warnings: string[], file: string }}
|
|
62
|
+
*/
|
|
63
|
+
function readDevRoots(home) {
|
|
64
|
+
const file = devRootsFile(home)
|
|
65
|
+
const fallback = { roots: [defaultDevRoot(home)], warnings: [], file }
|
|
66
|
+
if (!existsSync(file)) return fallback
|
|
67
|
+
let parsed = null
|
|
68
|
+
try { parsed = JSON.parse(readFileSync(file, 'utf8')) } catch (error) {
|
|
69
|
+
return { ...fallback, roots: [defaultDevRoot(home)], warnings: [`${file} 读不出来或不是合法 JSON(${error instanceof Error ? error.message : String(error)});已按默认根处理`] }
|
|
70
|
+
}
|
|
71
|
+
const list = parsed !== null && typeof parsed === 'object' && Array.isArray(parsed.roots) ? parsed.roots : null
|
|
72
|
+
if (list === null) return { ...fallback, warnings: [`${file} 缺少 roots 数组;已按默认根处理`] }
|
|
73
|
+
const roots = list.filter((x) => typeof x === 'string' && x !== '')
|
|
74
|
+
if (roots.length === 0) return fallback
|
|
75
|
+
return { roots, warnings: [], file }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 原子写登记表(备份 + 临时文件 + rename)。 */
|
|
79
|
+
function writeDevRoots(home, roots) {
|
|
80
|
+
const file = devRootsFile(home)
|
|
81
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
82
|
+
const tmp = `${file}.tmp-${Date.now()}`
|
|
83
|
+
writeFileSync(tmp, `${JSON.stringify({ roots }, null, 2)}\n`, 'utf8')
|
|
84
|
+
renameSync(tmp, file)
|
|
85
|
+
return file
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 判定一个目录是否可以成为"开发区里的目标"。
|
|
90
|
+
*
|
|
91
|
+
* @returns {{ ok: boolean, reason: string|null, normalized: string|null, root: string|null }}
|
|
92
|
+
* ok=false 时 reason 是**给人看的**一句话(面板直接显示)。
|
|
93
|
+
*/
|
|
94
|
+
function checkInsideDevRoots(home, candidate) {
|
|
95
|
+
const norm = normalizePath(candidate)
|
|
96
|
+
if (norm === null) return { ok: false, reason: '没有提供目录', normalized: null, root: null }
|
|
97
|
+
if (!existsSync(candidate)) return { ok: false, reason: `目录不存在:${candidate}`, normalized: norm, root: null }
|
|
98
|
+
let isDir = false
|
|
99
|
+
try { isDir = statSync(candidate).isDirectory() } catch {}
|
|
100
|
+
if (!isDir) return { ok: false, reason: `不是目录:${candidate}`, normalized: norm, root: null }
|
|
101
|
+
const { roots } = readDevRoots(home)
|
|
102
|
+
for (const root of roots) {
|
|
103
|
+
const rootNorm = normalizePath(root)
|
|
104
|
+
if (isInside(norm, rootNorm)) return { ok: true, reason: null, normalized: norm, root }
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
ok: false,
|
|
108
|
+
reason: `不在任何已注册的开发区根里:${candidate}\n已注册:${roots.join(' / ')}\n(这是刻意的限制:只允许钉住开发区里的目录,不允许任意路径)`,
|
|
109
|
+
normalized: norm,
|
|
110
|
+
root: null,
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 注册一个新根(必须存在、是目录、不与已有根重复/互相包含)。返回 { ok, error, roots, file }。 */
|
|
115
|
+
function addDevRoot(home, dir) {
|
|
116
|
+
const norm = normalizePath(dir)
|
|
117
|
+
if (norm === null) return { ok: false, error: '没有提供目录', roots: readDevRoots(home).roots, file: devRootsFile(home) }
|
|
118
|
+
if (!existsSync(dir)) return { ok: false, error: `目录不存在:${dir}`, roots: readDevRoots(home).roots, file: devRootsFile(home) }
|
|
119
|
+
let isDir = false
|
|
120
|
+
try { isDir = statSync(dir).isDirectory() } catch {}
|
|
121
|
+
if (!isDir) return { ok: false, error: `不是目录:${dir}`, roots: readDevRoots(home).roots, file: devRootsFile(home) }
|
|
122
|
+
const current = readDevRoots(home)
|
|
123
|
+
const normRoots = current.roots.map((r) => normalizePath(r))
|
|
124
|
+
if (normRoots.includes(norm)) return { ok: true, error: null, roots: current.roots, file: current.file, note: '已经注册过了' }
|
|
125
|
+
for (const [i, rootNorm] of normRoots.entries()) {
|
|
126
|
+
if (isInside(norm, rootNorm) || isInside(rootNorm, norm)) {
|
|
127
|
+
return { ok: false, error: `与已注册的根重叠:${current.roots[i]}(不要嵌套注册,包含关系已覆盖)`, roots: current.roots, file: current.file }
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const roots = [...current.roots, resolve(dir)]
|
|
131
|
+
return { ok: true, error: null, roots, file: writeDevRoots(home, roots) }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** 取消注册(默认根不允许取消 —— 它是兜底,去掉会让人连自己现有的插件都钉不回来)。 */
|
|
135
|
+
function removeDevRoot(home, dir) {
|
|
136
|
+
const norm = normalizePath(dir)
|
|
137
|
+
const current = readDevRoots(home)
|
|
138
|
+
const idx = current.roots.findIndex((r) => normalizePath(r) === norm)
|
|
139
|
+
if (idx < 0) return { ok: false, error: `未注册:${dir}`, roots: current.roots, file: current.file }
|
|
140
|
+
if (norm === normalizePath(defaultDevRoot(home))) {
|
|
141
|
+
return { ok: false, error: `默认根不能取消注册:${defaultDevRoot(home)}(它是兜底)`, roots: current.roots, file: current.file }
|
|
142
|
+
}
|
|
143
|
+
const roots = current.roots.filter((_, i) => i !== idx)
|
|
144
|
+
return { ok: true, error: null, roots, file: writeDevRoots(home, roots) }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export { DEV_ROOTS_FILE, homeDirOf, devRootsFile, defaultDevRoot, normalizePath, isInside, readDevRoots, writeDevRoots, checkInsideDevRoots, addDevRoot, removeDevRoot }
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// L1 · domain —— 把"开发区里的某个目录"钉成一个 link 依赖(0.5.57 加法)
|
|
2
|
+
//
|
|
3
|
+
// 用户决定(2026-10-15 原话):① 允许注册自己的开发区根;② **不允许任意目录**;③ agent 自动钉住要用户勾选,
|
|
4
|
+
// 且**默认关闭**。本模块是 ②③ 的执行点:
|
|
5
|
+
// · 目标必须落在已注册的根里(判据在 domain/dev-roots.js,本模块**不重复实现**);
|
|
6
|
+
// · `allowAgent` 必须显式为 true 才允许"由 agent 发起"的钉住(默认 false = 只接受用户显式请求)。
|
|
7
|
+
//
|
|
8
|
+
// 动作分两段,和仓库其它写动作一致:
|
|
9
|
+
// planPinDevDir —— **纯只读**:校验 + 产出一份"将要发生什么"的清单(面板先给用户看,人性化);
|
|
10
|
+
// applyPinDevDir —— 真的写:备份清单 → 写 `link:<dir>` → 跑 pnpm(可注入)→ **读回归属与 lock** →
|
|
11
|
+
// 失败即回滚清单并如实报错(绝不谎报"钉住了")。
|
|
12
|
+
//
|
|
13
|
+
// 为什么必须读回验证:`link:` 的"钉住了"判据是**清单里该包是 link: 形式且 lock 跟上了**,
|
|
14
|
+
// 不是"我写进去了"。pnpm 可能把绝对路径规范化、也可能因供应链闸/构建脚本失败而根本没写成。
|
|
15
|
+
//
|
|
16
|
+
// 分层:L1 domain —— 收已解开的 home/profileDir/deps;不认 ctx。pnpm 通过 deps.runInstall 注入(便于离线测试)。
|
|
17
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
18
|
+
import { join } from 'node:path'
|
|
19
|
+
import { checkInsideDevRoots } from './dev-roots.js'
|
|
20
|
+
|
|
21
|
+
const readJsonSafe = (file) => {
|
|
22
|
+
try { return JSON.parse(readFileSync(file, 'utf8')) } catch { return null }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 该目录是不是一个 dsh 插件(有 dsh.bundle / dsh.client / dsh.profile 任一标记)。
|
|
26
|
+
* 与 install-job 的检测同口径(那套是"清单没有标志 ≠ 不是插件"的判据真源)。 */
|
|
27
|
+
function dshMarkerOf(pkg) {
|
|
28
|
+
const dsh = pkg?.dsh
|
|
29
|
+
if (dsh === null || dsh === undefined || typeof dsh !== 'object') return null
|
|
30
|
+
for (const key of ['bundle', 'client', 'profile']) {
|
|
31
|
+
if (dsh[key] !== undefined && dsh[key] !== null) return key
|
|
32
|
+
}
|
|
33
|
+
return null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 规划一次钉住(**纯只读**)。
|
|
38
|
+
*
|
|
39
|
+
* @param {object} p
|
|
40
|
+
* @param {string} [p.home] DSH 家目录(读开发区登记表)
|
|
41
|
+
* @param {string} p.profileDir profile 根目录
|
|
42
|
+
* @param {string} p.dir 要钉的目录(绝对路径)
|
|
43
|
+
* @param {boolean} [p.allowAgent=false] 是否允许"由 agent 发起"(默认关闭,用户勾选后才 true)
|
|
44
|
+
* @param {boolean} [p.byAgent=false] 本次请求是否真的由 agent 发起
|
|
45
|
+
* @returns {{ ok: boolean, status: number, error: string|null, steps: string[],
|
|
46
|
+
* packageName: string|null, version: string|null, spec: string|null,
|
|
47
|
+
* current: string|null, marker: string|null, root: string|null, needsConfirm: boolean }}
|
|
48
|
+
*/
|
|
49
|
+
function planPinDevDir({ home = null, profileDir, dir, allowAgent = false, byAgent = false } = {}) {
|
|
50
|
+
const base = {
|
|
51
|
+
ok: false, status: 400, error: null, steps: [], packageName: null, version: null,
|
|
52
|
+
spec: null, current: null, marker: null, root: null, needsConfirm: true,
|
|
53
|
+
}
|
|
54
|
+
// ③ agent 发起必须被明确放行(默认关闭)
|
|
55
|
+
if (byAgent === true && allowAgent !== true) {
|
|
56
|
+
return { ...base, status: 403, error: '看起来是 agent 发起的钉住,但「允许 agent 自动钉住」这个开关是关着的(默认关闭)—— 请在面板里勾选后再试,或由你自己点按钮' }
|
|
57
|
+
}
|
|
58
|
+
if (typeof profileDir !== 'string' || profileDir === '') return { ...base, error: '拿不到 profile 目录,拒绝改动' }
|
|
59
|
+
// ② 必须落在已注册的开发区根里(唯一执行点)
|
|
60
|
+
const inside = checkInsideDevRoots(home, dir)
|
|
61
|
+
if (inside.ok !== true) return { ...base, status: 403, error: inside.reason }
|
|
62
|
+
// 必须是一份能读的 package.json
|
|
63
|
+
const pkgPath = join(dir, 'package.json')
|
|
64
|
+
if (!existsSync(pkgPath)) return { ...base, status: 400, error: `${dir} 里没有 package.json —— 这不是一个可安装的包` }
|
|
65
|
+
const pkg = readJsonSafe(pkgPath)
|
|
66
|
+
if (pkg === null || typeof pkg !== 'object') return { ...base, status: 400, error: `${pkgPath} 读不出来或不是合法 JSON` }
|
|
67
|
+
if (typeof pkg.name !== 'string' || pkg.name === '') return { ...base, status: 400, error: `${pkgPath} 没有 name 字段` }
|
|
68
|
+
const marker = dshMarkerOf(pkg)
|
|
69
|
+
if (marker === null) {
|
|
70
|
+
return { ...base, status: 400, error: `${pkg.name} 的 package.json 里没有 dsh.bundle / dsh.client / dsh.profile —— 它不是 dsh 插件,钉住它没有意义` }
|
|
71
|
+
}
|
|
72
|
+
const version = typeof pkg.version === 'string' ? pkg.version : null
|
|
73
|
+
// 现值(用于"改了没有"的判定与人性化预览)
|
|
74
|
+
const manifest = readJsonSafe(join(profileDir, 'package.json')) ?? {}
|
|
75
|
+
const deps = manifest.dependencies !== null && typeof manifest.dependencies === 'object' ? manifest.dependencies : {}
|
|
76
|
+
const current = typeof deps[pkg.name] === 'string' ? deps[pkg.name] : null
|
|
77
|
+
const spec = `link:${dir}`
|
|
78
|
+
const steps = [
|
|
79
|
+
`把 ${pkg.name} 的来源写成 ${spec}(清单 dependencies)`,
|
|
80
|
+
'跑一次 pnpm install --lockfile-only,让 pnpm-lock.yaml 与清单一致',
|
|
81
|
+
'补该插件的自足性(缺的 dependencies 交给 pnpm,框架 peer 走垫片)',
|
|
82
|
+
'读回归属与 lock 复核:只有两处都到位才算钉住',
|
|
83
|
+
]
|
|
84
|
+
return { ...base, ok: true, status: 200, packageName: pkg.name, version, spec, current, marker, root: inside.root, steps }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 执行钉住。**失败即回滚清单**,绝不留半截状态、绝不谎报成功。
|
|
89
|
+
*
|
|
90
|
+
* @param {object} p — 同 planPinDevDir,外加:
|
|
91
|
+
* @param {(args: object) => Promise<{ ok: boolean, stderr?: string, note?: string }>} [p.runInstall]
|
|
92
|
+
* 注入的 pnpm 执行器(离线测试用);缺省走真实 pnpm。
|
|
93
|
+
* @param {(args: object) => Promise<object>} [p.ensureSufficiency] 注入的自足性执行器
|
|
94
|
+
*/
|
|
95
|
+
async function applyPinDevDir({ home = null, profileDir, dir, allowAgent = false, byAgent = false, runInstall = null, ensureSufficiency = null } = {}) {
|
|
96
|
+
const plan = planPinDevDir({ home, profileDir, dir, allowAgent, byAgent })
|
|
97
|
+
if (plan.ok !== true) return { ...plan, changed: false, backup: null }
|
|
98
|
+
const manifestPath = join(profileDir, 'package.json')
|
|
99
|
+
const before = readFileSync(manifestPath, 'utf8')
|
|
100
|
+
const backup = `${manifestPath}.bak-pin-${Date.now()}`
|
|
101
|
+
try { writeFileSync(backup, before, 'utf8') } catch {}
|
|
102
|
+
const rollback = (reason) => {
|
|
103
|
+
try { writeFileSync(manifestPath, before, 'utf8') } catch {}
|
|
104
|
+
return { ...plan, ok: false, changed: false, status: 500, error: `${reason}\n(清单已回滚到改动前,backup: ${backup})`, backup }
|
|
105
|
+
}
|
|
106
|
+
// ① 写清单
|
|
107
|
+
try {
|
|
108
|
+
const manifest = JSON.parse(before)
|
|
109
|
+
manifest.dependencies = manifest.dependencies !== null && typeof manifest.dependencies === 'object' ? manifest.dependencies : {}
|
|
110
|
+
manifest.dependencies[plan.packageName] = plan.spec
|
|
111
|
+
writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8')
|
|
112
|
+
} catch (error) {
|
|
113
|
+
return rollback(`写 profile 清单失败:${error instanceof Error ? error.message : String(error)}`)
|
|
114
|
+
}
|
|
115
|
+
// ② pnpm:lock 与清单对齐(失败即回滚)
|
|
116
|
+
let installNote = null
|
|
117
|
+
try {
|
|
118
|
+
const run = typeof runInstall === 'function' ? await runInstall({ profileDir, spec: plan.spec }) : { ok: true, skipped: true }
|
|
119
|
+
if (run?.ok !== true) {
|
|
120
|
+
return rollback(`pnpm 未能让 lock 与清单一致${run?.stderr ? `:${String(run.stderr).slice(0, 600)}` : ''}`)
|
|
121
|
+
}
|
|
122
|
+
installNote = run?.note ?? null
|
|
123
|
+
} catch (error) {
|
|
124
|
+
return rollback(`pnpm 执行失败:${error instanceof Error ? error.message : String(error)}`)
|
|
125
|
+
}
|
|
126
|
+
// ③ 自足性(失败不致命:如实记下,钉住本身已经成立)
|
|
127
|
+
let sufficiency = null
|
|
128
|
+
try {
|
|
129
|
+
if (typeof ensureSufficiency === 'function') sufficiency = await ensureSufficiency({ profileDir, packageName: plan.packageName })
|
|
130
|
+
} catch (error) {
|
|
131
|
+
sufficiency = { ok: false, note: error instanceof Error ? error.message : String(error) }
|
|
132
|
+
}
|
|
133
|
+
// ④ 读回验证:清单里必须是 link: 形式(pnpm 可能规范化路径,所以只认前缀)
|
|
134
|
+
const after = readJsonSafe(manifestPath)
|
|
135
|
+
const afterSpec = typeof after?.dependencies?.[plan.packageName] === 'string' ? after.dependencies[plan.packageName] : null
|
|
136
|
+
const linked = typeof afterSpec === 'string' && afterSpec.startsWith('link:')
|
|
137
|
+
if (!linked) return rollback(`写完读回发现清单里不是 link: 形式(实际 ${JSON.stringify(afterSpec)})`)
|
|
138
|
+
return {
|
|
139
|
+
...plan,
|
|
140
|
+
ok: true,
|
|
141
|
+
status: 200,
|
|
142
|
+
changed: true,
|
|
143
|
+
backup,
|
|
144
|
+
after: afterSpec,
|
|
145
|
+
note: installNote,
|
|
146
|
+
sufficiency: sufficiency === null ? null : { ok: sufficiency.ok === true, ready: sufficiency.ready === true, note: sufficiency.note ?? null, created: Array.isArray(sufficiency.created) ? sufficiency.created.length : 0 },
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export { dshMarkerOf, planPinDevDir, applyPinDevDir }
|
|
@@ -30,7 +30,7 @@ import { routeEnrich, routeMarketIndex, routeRepo, routeSearch, routeSubpackages
|
|
|
30
30
|
import { routeAdaptUnlock, routeAdaptUnlockAll, routeCleanResiduals, routeSelfUpdate, routeToggle, routeUninstall } from './plugins.js'
|
|
31
31
|
import { routeSkillRemove, routeSkillToggle, routeSkillsInstalledGet } from './skills.js'
|
|
32
32
|
import { routeGiteeOauthCallbackGet, routeGiteeOauthUrlGet, routeRegistryScan, routeSources, routeSourcesGet } from './sources.js'
|
|
33
|
-
import { routeDetails, routeLinksGet, routeStateGet } from './state.js'
|
|
33
|
+
import { routeCompatExempt, routeDetails, routeDevRoots, routeLinksGet, routePinDevDir, routeStateGet } from './state.js'
|
|
34
34
|
|
|
35
35
|
const ROUTE_PREFIX = '/plugin-console'
|
|
36
36
|
|
|
@@ -38,6 +38,11 @@ const ROUTES_EARLY = [
|
|
|
38
38
|
{ methods: ['GET'], path: `${ROUTE_PREFIX}/state`, handler: routeStateGet },
|
|
39
39
|
// link 开发式安装清单(0.5.54 加法):纯只读,供面板渲染"已挂载 / 自足性 / 垫片 / 持久化"
|
|
40
40
|
{ methods: ['GET'], path: `${ROUTE_PREFIX}/links`, handler: routeLinksGet },
|
|
41
|
+
// 兼容豁免(0.5.56 加法):把"被跳过的 bundle"的三条出路之一做成真正可点的动作
|
|
42
|
+
{ methods: ['POST'], path: `${ROUTE_PREFIX}/compat-exempt`, handler: routeCompatExempt },
|
|
43
|
+
// 开发区根 + 钉住(0.5.57 加法):允许注册自己的根;不允许任意目录;agent 发起需用户勾选(默认关)
|
|
44
|
+
{ methods: ['POST'], path: `${ROUTE_PREFIX}/dev-roots`, handler: routeDevRoots },
|
|
45
|
+
{ methods: ['POST'], path: `${ROUTE_PREFIX}/pin-dev-dir`, handler: routePinDevDir },
|
|
41
46
|
{ methods: ['GET'], path: `${ROUTE_PREFIX}/sources`, handler: routeSourcesGet },
|
|
42
47
|
{ methods: ['GET'], path: `${ROUTE_PREFIX}/gitee-oauth-url`, handler: routeGiteeOauthUrlGet },
|
|
43
48
|
{ methods: ['GET'], path: `${ROUTE_PREFIX}/gitee-oauth-callback`, handler: routeGiteeOauthCallbackGet },
|
|
@@ -11,6 +11,11 @@ import { readExtraBundleRows } from '../domain/install-job.js'
|
|
|
11
11
|
import { bundleVetoList } from '../domain/bundle-veto.js'
|
|
12
12
|
import { buildLinkInventory } from '../domain/link-inventory.js'
|
|
13
13
|
import { buildInstallHealth } from '../domain/install-health.js'
|
|
14
|
+
import { applyExemption } from '../domain/compat-exempt.js'
|
|
15
|
+
import { addDevRoot, readDevRoots, removeDevRoot } from '../domain/dev-roots.js'
|
|
16
|
+
import { applyPinDevDir, planPinDevDir } from '../domain/pin-dev-dir.js'
|
|
17
|
+
import { reconcileLockfile } from '../domain/selfupdate.js'
|
|
18
|
+
import { ensureLinkSelfSufficiency } from '../domain/link-self-sufficiency.js'
|
|
14
19
|
import { readBundlesSync } from '../domain/persist.js'
|
|
15
20
|
import { failedJobEvidence } from '../domain/install-verify.js'
|
|
16
21
|
import { installJobView, readGithubAuth } from '../domain/install.js'
|
|
@@ -224,4 +229,104 @@ async function routeLinksGet(req, res, rc) {
|
|
|
224
229
|
sendJson(res, 200, payload)
|
|
225
230
|
}
|
|
226
231
|
|
|
227
|
-
|
|
232
|
+
/**
|
|
233
|
+
* POST /compat-exempt —— **写**一条精确版本豁免(0.5.56 加法)。
|
|
234
|
+
*
|
|
235
|
+
* 0.5.53 让"被框架跳过的 bundle"可见,并在提示里给了三条出路;但控制台只会**读** compatibility.json,
|
|
236
|
+
* 从不写 —— 那条提示等于把活推回用户("你自己去编辑文件,格式自己查")。本路由把那半截补上。
|
|
237
|
+
*
|
|
238
|
+
* 安全与纪律(与仓库其它写动作一致):
|
|
239
|
+
* · 只接受 { packageName, version, mode },**不收任何路径** —— 文件位置由服务端按 profile 决定;
|
|
240
|
+
* · 精确版本校验(范围/前缀一律拒),框架版本必须是**当前运行时**那个;
|
|
241
|
+
* · 先备份 → 原子写 → **读回来验证**,验证不过即回滚并如实报错(绝不谎报成功);
|
|
242
|
+
* · 已有坏文件时**停止**而不是覆盖(那是用户的文件)。
|
|
243
|
+
*/
|
|
244
|
+
async function routeCompatExempt(req, res, rc) {
|
|
245
|
+
const ctx = rc.ctx
|
|
246
|
+
const body = rc.body ?? {}
|
|
247
|
+
let profileDir = null
|
|
248
|
+
try { profileDir = dirname(findPatchPath(ctx)) } catch {}
|
|
249
|
+
const frameworkVersion = typeof body.frameworkVersion === 'string' && body.frameworkVersion !== ''
|
|
250
|
+
? body.frameworkVersion
|
|
251
|
+
: (() => { try { return currentFrameworkVersion(ctx) } catch { return null } })()
|
|
252
|
+
const result = applyExemption({
|
|
253
|
+
profileDir,
|
|
254
|
+
packageName: body.packageName,
|
|
255
|
+
version: body.version,
|
|
256
|
+
frameworkVersion,
|
|
257
|
+
mode: body.mode === 'revoke' ? 'revoke' : 'grant',
|
|
258
|
+
})
|
|
259
|
+
const payload = {
|
|
260
|
+
ok: result.ok === true,
|
|
261
|
+
changed: result.changed === true,
|
|
262
|
+
key: result.key ?? null,
|
|
263
|
+
note: result.note ?? null,
|
|
264
|
+
error: result.error ?? null,
|
|
265
|
+
backup: result.backup ?? null,
|
|
266
|
+
}
|
|
267
|
+
if (result.ok === true) sendJson(res, 200, payload)
|
|
268
|
+
else sendError(res, result.status ?? 400, payload.error ?? '豁免写入失败', payload)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* POST /dev-roots —— 开发区根的登记表(0.5.57 加法)。
|
|
273
|
+
* 用户 2026-10-15 决定:「允许」注册自己的开发区根、「不允许」任意目录。
|
|
274
|
+
* 本路由只管登记表本身;**包含性判定**在 domain/dev-roots.js,是"不许任意目录"的唯一执行点。
|
|
275
|
+
* body: { action: 'list' | 'add' | 'remove', dir? }
|
|
276
|
+
*/
|
|
277
|
+
async function routeDevRoots(req, res, rc) {
|
|
278
|
+
const body = rc.body ?? {}
|
|
279
|
+
const action = body.action === 'add' ? 'add' : body.action === 'remove' ? 'remove' : 'list'
|
|
280
|
+
if (action === 'list') {
|
|
281
|
+
const r = readDevRoots(null)
|
|
282
|
+
sendJson(res, 200, { ok: true, roots: r.roots, warnings: r.warnings, file: r.file })
|
|
283
|
+
return
|
|
284
|
+
}
|
|
285
|
+
const result = action === 'add' ? addDevRoot(null, body.dir) : removeDevRoot(null, body.dir)
|
|
286
|
+
const payload = { ok: result.ok === true, roots: result.roots ?? [], file: result.file ?? null, note: result.note ?? null, error: result.error ?? null }
|
|
287
|
+
if (result.ok === true) sendJson(res, 200, payload)
|
|
288
|
+
else sendError(res, 400, result.error ?? '开发区根操作失败', payload)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* POST /pin-dev-dir —— 把一个**开发区里的目录**钉成 link 依赖(0.5.57 加法)。
|
|
293
|
+
*
|
|
294
|
+
* 用户 2026-10-15 决定:① 允许注册自己的根;② 不允许任意目录;③ agent 自动钉住要用户勾选、**默认关闭**。
|
|
295
|
+
* 所以本路由有两段,都是刻意的:
|
|
296
|
+
* { dir, confirm: false }(默认)→ **只规划**,返回"将要发生什么"的清单(面板先给人看);
|
|
297
|
+
* { dir, confirm: true } → 真写(备份清单 → 写 link: → pnpm 对齐 lock → 自足性 → 读回验证)。
|
|
298
|
+
* `byAgent: true` 时服务端会检查 `allowAgent`(面板上的勾选,默认关),未放行一律 403。
|
|
299
|
+
*/
|
|
300
|
+
async function routePinDevDir(req, res, rc) {
|
|
301
|
+
const ctx = rc.ctx
|
|
302
|
+
const body = rc.body ?? {}
|
|
303
|
+
let profileDir = null
|
|
304
|
+
try { profileDir = dirname(findPatchPath(ctx)) } catch {}
|
|
305
|
+
const common = {
|
|
306
|
+
profileDir,
|
|
307
|
+
dir: body.dir,
|
|
308
|
+
byAgent: body.byAgent === true,
|
|
309
|
+
allowAgent: body.allowAgent === true,
|
|
310
|
+
}
|
|
311
|
+
if (body.confirm !== true) {
|
|
312
|
+
const plan = planPinDevDir(common)
|
|
313
|
+
if (plan.ok !== true) { sendError(res, plan.status ?? 400, plan.error ?? '钉住规划失败', { ok: false }); return }
|
|
314
|
+
sendJson(res, 200, { ok: true, applied: false, packageName: plan.packageName, version: plan.version, spec: plan.spec, current: plan.current, marker: plan.marker, root: plan.root, steps: plan.steps })
|
|
315
|
+
return
|
|
316
|
+
}
|
|
317
|
+
// 真写:pnpm 通过真实执行器(这里用 install.js 的既有通道,与安装流程同一套)
|
|
318
|
+
const runInstall = async ({ profileDir: pd }) => {
|
|
319
|
+
const r = await reconcileLockfile({ profileDir: pd })
|
|
320
|
+
return { ok: r?.lockUpdated === true || r?.ok === true, note: r?.note ?? null, stderr: r?.stderr ?? null }
|
|
321
|
+
}
|
|
322
|
+
const ensureSufficiency = async ({ profileDir: pd, packageName }) => ensureLinkSelfSufficiency({ profileDir: pd, packageName })
|
|
323
|
+
const result = await applyPinDevDir({ ...common, runInstall, ensureSufficiency })
|
|
324
|
+
const payload = {
|
|
325
|
+
ok: result.ok === true, applied: result.changed === true, packageName: result.packageName ?? null,
|
|
326
|
+
spec: result.spec ?? null, after: result.after ?? null, error: result.error ?? null,
|
|
327
|
+
backup: result.backup ?? null, note: result.note ?? null, sufficiency: result.sufficiency ?? null,
|
|
328
|
+
}
|
|
329
|
+
if (result.ok === true) sendJson(res, 200, payload)
|
|
330
|
+
else sendError(res, result.status ?? 400, result.error ?? '钉住失败', payload)
|
|
331
|
+
}
|
|
332
|
+
export { routeStateGet, routeDetails, routeLinksGet, routeCompatExempt, routeDevRoots, routePinDevDir }
|
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.57",
|
|
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",
|