@noob-stupid/dsh-plugin-console 0.5.56 → 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 CHANGED
@@ -122,6 +122,18 @@ window.__ModuleLoader__.load({
122
122
  linksTitle: "开发式链接(link):共 {n} 个,自足 {ready} 个 —— ✓ 自足 · · 缺东西(悬停看原因)",
123
123
  healthSummary: "需要处理 {n} 类问题",
124
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 或验证失败已自动回滚)",
125
137
  exemptGrantTitle: "把「这个精确版本」在「当前框架版本」上授权给框架(写入 profile 的 compatibility.json,先备份、写完读回验证)",
126
138
  linksFailed: "开发式链接清单读取失败",
127
139
  actionReconcileLabel: "重建 pnpm-lock.yaml",
@@ -718,6 +730,18 @@ window.__ModuleLoader__.load({
718
730
  linksTitle: "Dev links (link:): {n} total, {ready} self-sufficient - checkmark = ready, dot = something missing (hover for why)",
719
731
  healthSummary: "{n} issue type(s) need attention",
720
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)",
721
745
  exemptGrantTitle: "Authorize this exact version on the current framework version (writes via compatibility.json in the profile, with backup and read-back verification)",
722
746
  linksFailed: "Failed to read the dev-link inventory",
723
747
  actionReconcileLabel: "Rebuild pnpm-lock.yaml",
@@ -6142,10 +6166,17 @@ window.__ModuleLoader__.load({
6142
6166
  // 过去 link 钉住的插件与普通插件长得一模一样 —— 看不出指向哪、目标还在不在、
6143
6167
  // 依赖/peer 齐不齐、垫片还在不在。这份清单让"看见"先成立。
6144
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);
6145
6175
  const load = async () => {
6146
6176
  try {
6147
6177
  // link 清单:独立 try(拿不到时这块显示"读取失败",绝不静默)
6148
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 {}
6149
6180
  const data = await call("/plugin-console/compat-status");
6150
6181
  if (data !== null) {
6151
6182
  setStatus(data);
@@ -6297,9 +6328,70 @@ window.__ModuleLoader__.load({
6297
6328
  + (Array.isArray(x.foreignPeers) && x.foreignPeers.length > 0 ? " · 异副本 " + x.foreignPeers.length : "")
6298
6329
  + (x.shims !== null && x.shims !== undefined ? " · 垫片 " + String(x.shims.count ?? 0) : "")
6299
6330
  + (x.persisted !== null && x.persisted !== undefined && x.persisted.ok === false ? " · 未持久化" : ""))))
6331
+
6300
6332
  : (links !== null && links.ok === false
6301
6333
  ? el("div", { style: { fontSize: "12px", marginTop: "4px", opacity: .8 } }, t("linksFailed") + ":" + String(links.error ?? "-"))
6302
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)),
6303
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),
6304
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),
6305
6397
  // 依赖锁体检 / 重建(2026-09-27 加法):体检只读;重建必须用户点(服务端也只认显式 POST)。
@@ -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 { routeCompatExempt, 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
 
@@ -40,6 +40,9 @@ const ROUTES_EARLY = [
40
40
  { methods: ['GET'], path: `${ROUTE_PREFIX}/links`, handler: routeLinksGet },
41
41
  // 兼容豁免(0.5.56 加法):把"被跳过的 bundle"的三条出路之一做成真正可点的动作
42
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 },
43
46
  { methods: ['GET'], path: `${ROUTE_PREFIX}/sources`, handler: routeSourcesGet },
44
47
  { methods: ['GET'], path: `${ROUTE_PREFIX}/gitee-oauth-url`, handler: routeGiteeOauthUrlGet },
45
48
  { methods: ['GET'], path: `${ROUTE_PREFIX}/gitee-oauth-callback`, handler: routeGiteeOauthCallbackGet },
@@ -12,6 +12,10 @@ 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
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'
15
19
  import { readBundlesSync } from '../domain/persist.js'
16
20
  import { failedJobEvidence } from '../domain/install-verify.js'
17
21
  import { installJobView, readGithubAuth } from '../domain/install.js'
@@ -264,4 +268,65 @@ async function routeCompatExempt(req, res, rc) {
264
268
  else sendError(res, result.status ?? 400, payload.error ?? '豁免写入失败', payload)
265
269
  }
266
270
 
267
- export { routeStateGet, routeDetails, routeLinksGet, routeCompatExempt }
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.56",
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",