@noob-stupid/dsh-plugin-console 0.5.46 → 0.5.48

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.
@@ -70,12 +70,39 @@ function pickSubdirPlugin(subs) {
70
70
  }
71
71
  }
72
72
 
73
+ /** 命中子目录插件时的**可安装下一步**(纯函数,单测覆盖)。
74
+ *
75
+ * 0.5.47 改错(用户实测):0.5.46 只做到"认出来了 + 给一段可复制的文字",**装不了** ——
76
+ * 用户拿到一句"插件在 ds-bot/ 下"之后还得自己想办法。现在把结构化的可执行计划一并带出:
77
+ * `{ action: 'install-subdir', repo, branch, dir, packageName, markers }`。
78
+ * 面板据此渲染一个**按钮**(而不是让用户从报错里抄路径),点下去就是把 `subdir` 交给既有的
79
+ * `/plugin-console/install` —— 由 domain/subdir-install.js 先落地该子目录、再按既有**本地目录来源**
80
+ * (`link:`,见 source-spec.js 的判据 C)装进 profile。
81
+ * 判据不放松:到服务端那一步仍要重新读 `<dir>/package.json` 并按同一把尺子核实(名字 + dsh 标记),
82
+ * 前端传什么都不算数。`markers` 只为展示判据来源;`repo`/`branch` 由**识别当时**的事实给出,
83
+ * 免得面板再猜一次。 */
84
+ function subdirInstallPlan(repo, branch, pick) {
85
+ const dir = String(pick?.dir ?? '')
86
+ const packageName = String(pick?.packageName ?? '')
87
+ if (dir === '' || packageName === '') return null
88
+ return {
89
+ action: 'install-subdir',
90
+ repo: String(repo ?? ''),
91
+ branch: String(branch ?? ''),
92
+ dir,
93
+ packageName,
94
+ markers: Array.isArray(pick?.markers) ? [...pick.markers] : [],
95
+ }
96
+ }
97
+
73
98
  /** 命中子目录插件时的用户可读结论(纯函数,单测覆盖)。
74
99
  * 要点(对应用户实测的质疑"它不是 DSH 插件吗"):
75
100
  * ① 先**纠正结论** —— 这不是"不是插件",而是"插件不在根目录";
76
101
  * ② 给出**子目录路径**与**包名**,以及标记来源(dsh.bundle.patch / dsh.client)—— 判据可核验;
77
- * ③ 给出**可复制的下一步**(按包名安装);并**如实说明**"从子目录直接装"当前不支持
78
- * (git 通道只装仓库根包,见 domain/git-channel.js 的核实逻辑)—— 不假装支持。 */
102
+ * ③ 给出**可安装的下一步**(0.5.47 改错):控制台自己能从这个子目录装 —— 面板上的按钮走
103
+ * `/plugin-console/install` + `subdir`,服务端先稀疏取该子目录源码到本地、再按既有
104
+ * **本地目录来源**(`link:`)装进 profile;清单校验 / 来源校验与别的安装路径**同一套**。
105
+ * npm 上若已发布该包,另给一条可直接照抄的命令(两条路都要说清,不二选一藏起来)。 */
79
106
  function subdirPluginText(repo, branch, pick, source = 'github') {
80
107
  const dir = String(pick?.dir ?? '')
81
108
  const pkgName = String(pick?.packageName ?? '')
@@ -83,9 +110,10 @@ function subdirPluginText(repo, branch, pick, source = 'github') {
83
110
  return `仓库 ${repo}(${branch} 分支)根目录没有 package.json,但**插件在子目录 ${dir}/ 下**`
84
111
  + `(${dir}/package.json 声明了 DSH 标记:${markers})。`
85
112
  + `它就是 DSH 插件,只是清单不在仓库根——${pkgName === '' ? '该子包的包名没能读到' : `包名是 ${pkgName}`}。`
86
- + `下一步:${pkgName === '' ? '先按「仓库落地」克隆到本地查看该子目录,' : `该包若已发布到 npm,可直接安装:dsh plugin --profile <你的 profile> add ${pkgName};否则`}`
87
- + `本控制台暂不支持"从子目录直接安装"(git 通道只装仓库根包,装了也是根包而不是 ${dir}/),`
88
- + `可把该子目录并进一个独立仓库或等作者发布 npm 包后再装。`
113
+ + `下一步:点下面的「从这个子目录安装」即可 —— 控制台会先把 ${dir}/ 的源码取到本地,`
114
+ + `再按既有**本地目录来源**(link:)装进 profile(清单校验与来源校验与其它安装路径同一套,`
115
+ + `装完照样收口:清单 + lock + 挂载行)。`
116
+ + `${pkgName === '' ? '' : `该包若已发布到 npm,也可以直接安装:dsh plugin --profile <你的 profile> add ${pkgName}。`}`
89
117
  }
90
118
 
91
119
  /** 扫完根目录与子目录都没找到插件时的用户可读结论(纯函数,单测覆盖)。
@@ -138,7 +166,8 @@ async function decideNoRootManifest({ repo, branch, reason, subScan = null, prob
138
166
  if (pick.found === true) {
139
167
  return {
140
168
  hint: 'subdir-plugin',
141
- subplugin: { dir: pick.dir, packageName: pick.packageName, markers: pick.markers, checkedDirs: pick.checkedDirs },
169
+ // 0.5.47:`install` 是**可执行**的下一步(面板据此渲染按钮);老客户端忽略该字段(纯加法)。
170
+ subplugin: { dir: pick.dir, packageName: pick.packageName, markers: pick.markers, checkedDirs: pick.checkedDirs, install: subdirInstallPlan(repo, branch, pick) },
142
171
  error: subdirPluginText(repo, branch, pick, source),
143
172
  }
144
173
  }
@@ -149,4 +178,4 @@ async function decideNoRootManifest({ repo, branch, reason, subScan = null, prob
149
178
  }
150
179
  }
151
180
 
152
- export { dshMarkersOf, describeDshManifest, pickSubdirPlugin, subdirPluginText, notPluginText, decideNoRootManifest }
181
+ export { dshMarkersOf, describeDshManifest, pickSubdirPlugin, subdirInstallPlan, subdirPluginText, notPluginText, decideNoRootManifest }
@@ -0,0 +1,375 @@
1
+ // L1 · domain —— subdir-install.js(「从这个子目录安装」:先落地该子目录,再按既有**本地目录来源**装)
2
+ //
3
+ // 真机现场(2026-10-13 用户实测,第二轮):`Fei2411/DeepSeek-Bot` 根目录没有 package.json,
4
+ // 插件清单在 `ds-bot/` 里。0.5.46 已经能**认出来**并给出可复制的一段文字 —— 但**装不了**:
5
+ // 用户拿到"插件在 ds-bot/ 下"之后还得自己想办法(把子目录并进独立仓库 / 等作者发 npm 包)。
6
+ // 本轮补上**可执行的那一步**:面板上点「从这个子目录安装」→ 这里跑完整条路。
7
+ //
8
+ // 实现方式(三条备选里选"先落地再本地装",理由写死在这里):
9
+ // ① 本地目录来源**本来就是这个仓的既有能力**(source-spec.js 的判据 C:`link:` 是本地目录的正常路径,
10
+ // 外加 link-self-sufficiency 的依赖/peer 垫片补齐)—— 不需要给 pnpm 编一个新协议;
11
+ // ② GitHub 侧不需要新接口:既有 `gitCloneRepo(..., { sparse: [dir] })` 就能**只拉那一个目录**
12
+ // (批次 D-② 的能力,预设通道已在用),代价与定位子包同一量级;
13
+ // ③ 已落地仓库优先复用(`findLandedRepo`)⇒ 用户先点过「仓库落地」时**零网络请求**。
14
+ //
15
+ // 判据一个字都不放松(用户点名:"不许降低任何既有校验标准"):
16
+ // · **清单校验**:先去仓库读 `<dir>/package.json`(走 infra/http.js 的既有读取通道,
17
+ // 与根清单探测同一把尺子),必须是合法 JSON、必须有 name、必须带 dsh 标记(dsh.bundle.patch / dsh.client)
18
+ // —— 与识别阶段(subdir-detect.js)用**同一个** describeDshManifest/dshMarkersOf;
19
+ // · **来源校验**:包名以**清单**为准(前端传什么都不算数),且取到源码之后**读回本地清单再核一次**
20
+ // (名字不一致 ⇒ 当场拒绝,绝不按别人的包名装一份不知道是什么的东西);
21
+ // · **装后/收口**:与普通安装同一条路 —— verifyInstalledEntry(只提示)+ bundle patch 引用的包必须
22
+ // 能解析(缺一个就拒绝注册,理由同 install-job.js 那段崩溃事故)+ finalizeInstallPersistence
23
+ // (清单 + lock + 挂载行)。
24
+ //
25
+ // 分层:L1 · domain —— 不得出现 cordis ctx(test-architecture-guard 断言⑤);进程/出网都只经
26
+ // 可注入的单点(`deps.channels.pnpmInstall` / deps.gitClone / deps.readManifest),测试一律注入替身。
27
+
28
+ import { existsSync, readFileSync } from 'node:fs'
29
+ import { dirname, join } from 'node:path'
30
+ import { readBundlePatchRefNames } from './bundle-refs.js'
31
+ import { PLUGIN_SRC_DIR, linkSpecFor, sameSourceSpec } from './dep-source.js'
32
+ import { noteChannel } from './git-channel.js'
33
+ import { addBundleToManifest, detectBundleOnly } from './install.js'
34
+ import { finalizeInstallPersistence } from './install-finalize.js'
35
+ import { alreadyInstalledTarget, verifyInstalledEntry } from './install-verify.js'
36
+ import { ensureLinkSelfSufficiency } from './link-self-sufficiency.js'
37
+ import { setProfileDependency } from './manifest.js'
38
+ import { fetchRepoPathText } from './market.js'
39
+ import { findLandedRepo, gitCloneRepo } from './repoland.js'
40
+ import { listEntries } from './runtime.js'
41
+ import { orderedRegistries, readSources } from './sources.js'
42
+ import { describeDshManifest } from './subdir-detect.js'
43
+ import { fetchRepoMeta } from './suite.js'
44
+ import { copyTree, disposeDir, disposeNote } from '../infra/fsx.js'
45
+ import { dshHome, findPatchPath, packageNameOf, resolvePackageJson } from '../infra/paths.js'
46
+
47
+ /** 落地源码的暂存目录名(`<DSH_HOME>/plugin-console/<这个名字>/<repo>__<dir>`):
48
+ * 与 `source-specs/` / 自足记录同族都在 `plugin-console/` 下 —— 它是"取下来的工作副本",
49
+ * 插件的**正式家**仍是 `<DSH_HOME>/plugin-src/<包名>`(pnpm 管不到、跨 lock 重建稳定存在)。 */
50
+ const SUBDIR_SRC_DIR = 'subdir-src'
51
+
52
+ /** 子目录路径的一节:字母数字 . _ -,且**不许**是 `.` / `..`(路径穿越)。 */
53
+ const SUBDIR_SEGMENT_RE = /^(?!\.{1,2}$)[A-Za-z0-9._-]+$/u
54
+ /** 识别阶段的口径:只认第 1~2 层子目录(`<dir>` / `<dir>/<sub>`),与市场探针完全一致。 */
55
+ const SUBDIR_MAX_SEGMENTS = 2
56
+
57
+ /** 归一化用户/前端给的子目录:合法则返回规范形,否则 null(绝不抛)。 */
58
+ function normalizeSubdir(raw) {
59
+ const text = String(raw ?? '').trim().replace(/^\.\//u, '').replace(/\/+$/u, '')
60
+ if (text === '') return null
61
+ if (text.includes('\\') || text.startsWith('/') || /^[A-Za-z]:/u.test(text)) return null
62
+ const parts = text.split('/')
63
+ if (parts.length === 0 || parts.length > SUBDIR_MAX_SEGMENTS) return null
64
+ if (!parts.every((part) => SUBDIR_SEGMENT_RE.test(part))) return null
65
+ return parts.join('/')
66
+ }
67
+
68
+ /** 暂存目录名(同一仓库的同一个子目录复用同一格;重跑会覆盖前一次的骨架)。 */
69
+ function subdirSlug(repo, dir) {
70
+ return `${String(repo).replace(/[^A-Za-z0-9._-]+/gu, '__')}__${String(dir).replace(/[^A-Za-z0-9._-]+/gu, '_')}`
71
+ }
72
+
73
+ /** 失败收口(唯一出口):状态 + 文案 + 附带的展示字段,绝不留半句话的结论。 */
74
+ function failSubdirInstall(job, text, extra = {}) {
75
+ job.status = 'failed'
76
+ job.error = text
77
+ for (const [key, value] of Object.entries(extra)) job[key] = value
78
+ return false
79
+ }
80
+
81
+ /** 子目录安装的**前置校验**:读 `<dir>/package.json` 并核实"它确实是 DSH 插件"。
82
+ * 返回 `{ ok, packageName, markers }` 或 `{ ok:false, error }`(文案即用户可见结论)。
83
+ * `readManifest(repo, branch, path)` 可注入(单测 ⇒ 零出网);生产走 market.fetchRepoPathText。 */
84
+ async function verifySubdirManifest({ repo, branch, dir, readManifest }) {
85
+ const path = `${dir}/package.json`
86
+ let text = null
87
+ let readError = null
88
+ try {
89
+ text = await readManifest(repo, branch, path)
90
+ } catch (error) {
91
+ readError = error instanceof Error ? error.message : String(error)
92
+ }
93
+ if (text === null || text === undefined || readError !== null) {
94
+ // "没读到"绝不许冒充"没有"(本仓既有口径):只说没读到 + 为什么 + 出路。
95
+ return {
96
+ ok: false,
97
+ error: `读不到 ${repo}(${branch} 分支)的 ${path}${readError === null ? '(404 / 超时 / 网络不可达)' : `:${readError}`}`
98
+ + ` —— 没能核实这个子目录里到底是什么,**没有**下"它不是插件"的结论。请重试;`
99
+ + `或先按「仓库落地」把仓库克隆到本地,本控制台会直接复用那份已落地的副本(零网络请求)。`,
100
+ }
101
+ }
102
+ const info = describeDshManifest(text)
103
+ if (info === null) {
104
+ return { ok: false, error: `${path} 不是合法的包描述(JSON 解析失败或缺少 name 字段)—— 无法作为插件安装。` }
105
+ }
106
+ if (info.markers.length === 0) {
107
+ return {
108
+ ok: false,
109
+ error: `${path} 里那个包(${info.name})没有声明任何 dsh 标记(dsh.bundle.patch / dsh.client)`
110
+ + ` —— 它不是 DSH 插件(判据与"是不是插件"的识别完全一致,不为安装放宽)。`,
111
+ }
112
+ }
113
+ return { ok: true, packageName: info.name, markers: info.markers }
114
+ }
115
+
116
+ /** 取子目录源码:先复用**已落地**仓库(零网络),否则稀疏取源码只拉这一个目录。
117
+ * 返回 `{ ok, dir, reused, notes }` / `{ ok:false, notes, error }`。clone 可注入(单测零进程/零网络)。
118
+ *
119
+ * ★ 2026-10-13(0.5.48)接线改错:把本次目标子目录 `dir` **显式传给 findLandedRepo**。
120
+ * 原来这里调 `findLanded(repo)` 不传子目录,而 findLandedRepo 的旧判据要求**仓库根**有
121
+ * package.json ⇒ 像 DeepSeek-Bot 这种"根目录故意没有清单、插件在子目录"的仓库**永远不命中**
122
+ * ⇒ 本地明明已经落地,还是往下走去网上取源码 —— 正是"已落地优先 ⇒ 零网络"对子目录插件的结构性失效。
123
+ * (下面那段"再看 candidate/package.json"的兜底是从**未命中**里救回来的补丁;判据修好后
124
+ * 正常路径在 findLandedRepo 内就命中,这段兜底保留作为行为不变的安全网。) */
125
+ async function acquireSubdirSource({ repo, dir, source = 'github', deps = {} }) {
126
+ const notes = []
127
+ const findLanded = typeof deps.findLandedRepo === 'function' ? deps.findLandedRepo : findLandedRepo
128
+ let landed = { hit: false, path: null }
129
+ try {
130
+ landed = findLanded(repo, { subdir: dir }) ?? landed
131
+ } catch {
132
+ landed = { hit: false, path: null }
133
+ }
134
+ if (landed?.hit === true && typeof landed.path === 'string' && landed.path !== '') {
135
+ const candidate = join(landed.path, ...dir.split('/'))
136
+ if (existsSync(join(candidate, 'package.json'))) {
137
+ notes.push(`复用已落地仓库里的子目录:${candidate}(零网络请求)`)
138
+ return { ok: true, dir: candidate, reused: true, notes }
139
+ }
140
+ notes.push(`本机有已落地仓库 ${landed.path},但里面没有 ${dir}/package.json —— 改用稀疏取源码`)
141
+ }
142
+ const scratchRoot = typeof deps.srcRoot === 'string' && deps.srcRoot !== ''
143
+ ? deps.srcRoot
144
+ : join(dshHome(), 'plugin-console', SUBDIR_SRC_DIR)
145
+ const dest = join(scratchRoot, subdirSlug(repo, dir))
146
+ const clone = typeof deps.gitClone === 'function' ? deps.gitClone : gitCloneRepo
147
+ const timeout = Number.isFinite(deps.cloneTimeoutMs) && deps.cloneTimeoutMs > 0 ? deps.cloneTimeoutMs : undefined
148
+ let cloned = null
149
+ try {
150
+ cloned = await clone(repo, dest, source, timeout, { sparse: [dir] })
151
+ } catch (error) {
152
+ return { ok: false, notes, error: `取子目录源码失败(${dir}/):${error instanceof Error ? error.message : String(error)}` }
153
+ }
154
+ if (cloned?.sparse === true) notes.push(`已用稀疏取源码(--filter=blob:none --sparse + sparse-checkout set ${dir}):只拉这 1 个子目录`)
155
+ else notes.push('稀疏取源码不被本机/对端支持(已降级到普通 clone)—— 体积更大,结果一样')
156
+ const src = join(dest, ...dir.split('/'))
157
+ if (!existsSync(join(src, 'package.json'))) {
158
+ return { ok: false, notes, error: `取到的源码里没有 ${dir}/package.json(克隆目标:${dest})—— 仓库布局可能已经变了` }
159
+ }
160
+ return { ok: true, dir: src, reused: cloned?.reused === true, notes }
161
+ }
162
+
163
+ /** 把取到的子目录放成插件的**正式家**:`<DSH_HOME>/plugin-src/<包名>`。
164
+ * 为什么必须先落这里再装:`link:` 的目标要能跨 pnpm 的 node_modules 重建活下来(plugin-src 是
165
+ * pnpm 不管的独立目录,见 dep-source.js 的 materializePackageForLink 注释)。
166
+ * 目标已存在 ⇒ 先让开(删除失败会降级改名成 `.trash-*`,原路径照样让开)。 */
167
+ function placeSubdirSource({ srcDir, packageName, home = null, deps = {} }) {
168
+ const base = typeof home === 'string' && home !== '' ? home : dshHome()
169
+ const dest = join(base, PLUGIN_SRC_DIR, ...String(packageName).split('/'))
170
+ const dispose = typeof deps.disposeDir === 'function' ? deps.disposeDir : disposeDir
171
+ try {
172
+ if (existsSync(dest)) {
173
+ const result = dispose(dest)
174
+ if (result?.ok !== true) return { ok: false, dir: dest, error: `目标目录 ${dest} 无法让开(${disposeNote(result)})` }
175
+ }
176
+ copyTree(srcDir, dest)
177
+ } catch (error) {
178
+ return { ok: false, dir: dest, error: `把子目录源码铺进 ${dest} 失败:${error instanceof Error ? error.message : String(error)}` }
179
+ }
180
+ if (!existsSync(join(dest, 'package.json'))) return { ok: false, dir: dest, error: `铺进 ${dest} 后读不到 package.json(复制未生效)` }
181
+ return { ok: true, dir: dest }
182
+ }
183
+
184
+ /** 铺好之后**读回本地清单再核一次**(来源校验):名字必须与远端读到的那个一致。
185
+ * 不一致 ⇒ 拒绝安装(绝不按 A 的名字装一份 B 的东西)。返回 `{ ok, version, error }`。 */
186
+ function verifyPlacedManifest({ dir, packageName }) {
187
+ try {
188
+ const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'))
189
+ if (pkg === null || typeof pkg !== 'object') return { ok: false, version: null, error: `${dir}/package.json 不是对象` }
190
+ if (pkg.name !== packageName) {
191
+ return { ok: false, version: null, error: `${dir}/package.json 里的包名是 ${String(pkg.name)},与远端读到的 ${packageName} 不一致 —— 已拒绝安装(来源存疑)` }
192
+ }
193
+ return { ok: true, version: typeof pkg.version === 'string' ? pkg.version : null, error: null }
194
+ } catch (error) {
195
+ return { ok: false, version: null, error: `读回 ${dir}/package.json 失败:${error instanceof Error ? error.message : String(error)}` }
196
+ }
197
+ }
198
+
199
+ /** 收口之后确保清单里记的是**来源规格**(`link:`),而不是被顺手写成的版本号。
200
+ *
201
+ * 为什么需要这一步(本轮施工实测到的一条真链路):`persist.js` 在 **bundle 模式**且该 bundle 的补丁里
202
+ * 读不到 insert 行时会调 `manifest.js#addBundleToManifest` 收尾,而那个实现会顺手把
203
+ * `dependencies[包名]` 写成**已装版本号**("安装即声明"的副产物)—— 对 registry 包没问题,
204
+ * 对**本地目录来源**就是丢了来源:下次任何 pnpm 操作都会按版本号去 registry 解析 → 404
205
+ * (本仓 0.5.20 / 0.5.38 / 0.5.39 反复修过的病根)。子目录安装天生就是"本地目录来源 + 可能带 bundle",
206
+ * 所以在**本模块自己的出口**把这件事故意补回来(只动"确实写歪了"的那一次;等价即零写盘)。
207
+ * 返回写入结果或 null(没动手)。 */
208
+ async function keepLinkSpec({ profileDir, packageName, spec, job = null }) {
209
+ if (typeof spec !== 'string' || !spec.startsWith('link:')) return null
210
+ let current = null
211
+ try {
212
+ current = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8'))?.dependencies?.[packageName] ?? null
213
+ } catch {
214
+ return null
215
+ }
216
+ if (sameSourceSpec(profileDir, current, spec)) return null
217
+ let written = null
218
+ try {
219
+ written = await setProfileDependency(profileDir, packageName, spec)
220
+ } catch (error) {
221
+ noteChannel(job, `${packageName}:清单里的依赖规格是 ${String(current)},改回来源规格失败(${error instanceof Error ? error.message : String(error)})—— 重试一次即可`)
222
+ return null
223
+ }
224
+ noteChannel(job, `${packageName}:清单里的依赖规格被写成了 ${String(current)}(bundle 收尾"安装即声明"的副作用),已改回**来源规格** ${spec} —— 否则下次 pnpm 操作会按版本号去 registry 解析并 404`)
225
+ return written
226
+ }
227
+
228
+ /** 「从这个子目录安装」的完整作业体(install-job 在 `job.subdir` 非空时把作业交给它)。
229
+ * 步骤与理由见文件头;任何一步失败都**如实失败 + 原因**,绝不留下"半装"的糊涂账。
230
+ * deps 是离线测试缝(与 marketProbes/sourceDeps 同一风格):readManifest / gitClone / channels /
231
+ * selfSufficiency / persistDeps / srcRoot / home 都可替换;生产调用方只传 `channels`。
232
+ * `deps.channels` 由 install-job 传入(沿用它的 `installChannels` 注入缝,不在这里再解释一遍那个缝)。 */
233
+ async function runSubdirInstallJob(job, ports, deps = {}) {
234
+ try {
235
+ return await runSubdirInstallJobInner(job, ports, deps)
236
+ } catch (error) {
237
+ // 作业体是"安装主路径"的一部分:**绝不把异常抛给调用方**(那会让面板永远转圈/整个请求 500)。
238
+ // 未预期的错误也如实收口:状态 + 原因 + 可重试。
239
+ return failSubdirInstall(job, `子目录安装失败(未预期错误):${error instanceof Error ? error.message : String(error)} —— 未完成的部分已如实停下,可重试。`)
240
+ }
241
+ }
242
+
243
+ async function runSubdirInstallJobInner(job, ports, deps = {}) {
244
+ const repo = String(job?.repo ?? '')
245
+ const requested = String(job?.packageName ?? '').trim() // 前端给的名字(只用来对账,绝不当判据)
246
+ const source = job?.source === 'gitee' ? 'gitee' : 'github'
247
+ const dir = normalizeSubdir(job?.subdir)
248
+ if (dir === null) {
249
+ return failSubdirInstall(job, `子目录路径不合法:${String(job?.subdir ?? '(空)')} —— 只接受仓库里第 1~2 层的相对目录(如 ds-bot 或 packages/foo),不接受绝对路径、盘符、反斜杠与 . / ..`)
250
+ }
251
+ // 分支:面板带着识别当时的默认分支来;没带就自己探一次(与其它路径同一个探测入口)。
252
+ let branch = typeof job?.branch === 'string' && job.branch.trim() !== '' ? job.branch.trim() : ''
253
+ if (branch === '') {
254
+ let meta = null
255
+ try {
256
+ meta = await (typeof deps.fetchRepoMeta === 'function' ? deps.fetchRepoMeta(repo) : fetchRepoMeta(repo))
257
+ } catch {
258
+ meta = null
259
+ }
260
+ branch = typeof meta?.default_branch === 'string' && meta.default_branch !== '' ? meta.default_branch : 'main'
261
+ }
262
+ job.stage = 'preparing'
263
+ const readManifest = typeof deps.readManifest === 'function'
264
+ ? deps.readManifest
265
+ : (r, b, p) => fetchRepoPathText(r, b, p, deps.readDeps ?? {})
266
+ // ① 清单校验(== 识别阶段同一把尺子)
267
+ const verified = await verifySubdirManifest({ repo, branch, dir, readManifest })
268
+ if (verified.ok !== true) {
269
+ // 核实没过 ⇒ **不**下"插件在 <子目录>/ 下"的结论(那正是要被纠正的误导):
270
+ // 只给"为什么没核实成 / 怎么办",下一步是重试或「仓库落地」(hint 沿用既有取值)。
271
+ return failSubdirInstall(job, verified.error, { hint: 'repo-land', subdirDir: dir, subplugin: null })
272
+ }
273
+ const packageName = verified.packageName
274
+ job.packageName = packageName
275
+ job.subdirDir = dir
276
+ job.subdirMarkers = verified.markers
277
+ if (requested !== '' && requested !== packageName) {
278
+ // 前端给的名字与清单不一致:以清单为准,但如实记一条(不悄悄改用户的意图)
279
+ noteChannel(job, `前端请求的包名是 ${requested},而 ${dir}/package.json 里的包名是 ${packageName} —— 以**清单**为准(来源校验一视同仁)`)
280
+ }
281
+ // ② 目标 profile
282
+ const patchPath = findPatchPath(ports)
283
+ const profileDir = dirname(patchPath)
284
+ const channels = deps.channels ?? {}
285
+ if (typeof channels.pnpmInstall !== 'function') {
286
+ return failSubdirInstall(job, '内部错误:子目录安装需要安装通道(ports.installChannels.pnpmInstall),本次没拿到 —— 未改动任何东西。')
287
+ }
288
+ const home = typeof deps.home === 'string' && deps.home !== '' ? deps.home : dshHome()
289
+ // ③ 已经装上了?(用户被"报失败"误导过的正是这一类)——命中就不重复下载,直接走收口/启用
290
+ let linkSpec = null
291
+ const already = alreadyInstalledTarget(profileDir, packageName, job)
292
+ if (already !== null) {
293
+ job.curlNote = already
294
+ noteChannel(job, already)
295
+ } else {
296
+ job.stage = 'installing'
297
+ // ④ 取源码(已落地仓库优先,零网络;否则稀疏只拉这一个子目录)
298
+ const acquired = await acquireSubdirSource({ repo, dir, source, deps })
299
+ for (const note of acquired.notes ?? []) noteChannel(job, note)
300
+ if (acquired.ok !== true) {
301
+ return failSubdirInstall(job, `${acquired.error}。请重试;也可先按「仓库落地」把仓库克隆到本地,本控制台会直接复用那份副本。`)
302
+ }
303
+ // ⑤′ 先核实**刚取到的那份源码**(读回本地清单核对包名)——绝不在核实之前覆盖用户已装好的副本
304
+ const srcCheck = verifyPlacedManifest({ dir: acquired.dir, packageName })
305
+ if (srcCheck.ok !== true) return failSubdirInstall(job, `${srcCheck.error}(取到的源码:${acquired.dir})`)
306
+ // ⑤ 放成插件的正式家(plugin-src/<包名>)并再核实一次(复制是否真的生效)
307
+ const placed = placeSubdirSource({ srcDir: acquired.dir, packageName, home, deps })
308
+ if (placed.ok !== true) return failSubdirInstall(job, placed.error)
309
+ const recheck = verifyPlacedManifest({ dir: placed.dir, packageName })
310
+ if (recheck.ok !== true) return failSubdirInstall(job, recheck.error)
311
+ const spec = linkSpecFor(placed.dir)
312
+ linkSpec = spec
313
+ job.subdirSource = { dir, from: acquired.dir, reused: acquired.reused === true, placed: placed.dir, version: recheck.version }
314
+ // ⑥ 本地目录来源 = 既有安装通道:`pnpm add link:<目录>` 建起 node_modules 里的那条链接
315
+ // (link: 是本地目录的正常形态,见 source-spec.js 判据 C;依赖由下一步的自足性补齐)
316
+ try {
317
+ await channels.pnpmInstall(profileDir, spec, undefined)
318
+ } catch (error) {
319
+ return failSubdirInstall(job, `从子目录 ${dir}/ 安装失败(本地目录来源 ${spec}):${error instanceof Error ? error.message : String(error)}。`
320
+ + `源码已取到本地(${placed.dir}),可重试;重试会复用已取到的源码。`)
321
+ }
322
+ // ⑦ 自足性(link: 的依赖 + 框架 peer 垫片):既有能力,失败只如实记 note,不影响安装判定
323
+ try {
324
+ const ensureSelf = typeof deps.selfSufficiency === 'function' ? deps.selfSufficiency : ensureLinkSelfSufficiency
325
+ const self = await ensureSelf({ profileDir, packageName, spec, home, deps: deps.selfDeps ?? {} })
326
+ if (self?.applies === true && typeof self.note === 'string' && self.note !== '') noteChannel(job, self.note)
327
+ } catch (error) {
328
+ noteChannel(job, `link 自足性补齐失败(不影响安装本身):${error instanceof Error ? error.message : String(error)}`)
329
+ }
330
+ }
331
+ job.stage = 'configuring'
332
+ job.entryCheck = verifyInstalledEntry(profileDir, packageName)
333
+ // ⑧ 挂载:bundle 层(dsh.bundle.patch)或普通 insert 行 —— 注册前的引用校验与 install-job 同一条规矩
334
+ const entries = listEntries(ports)
335
+ const served = entries.some((entry) => String(entry.moduleName).startsWith(packageName)
336
+ || String(entry.moduleName) === packageName
337
+ || packageNameOf(entry.moduleName) === packageName)
338
+ let mode = 'insert'
339
+ if (await detectBundleOnly(profileDir, packageName)) {
340
+ mode = 'bundle'
341
+ if (!served) {
342
+ const refs = readBundlePatchRefNames(profileDir, packageName)
343
+ const missing = refs.filter((n) => resolvePackageJson(n, profileDir) === null)
344
+ if (missing.length > 0) {
345
+ return failSubdirInstall(job, `该聚合包(${packageName})的插件组引用以下未安装模块:${missing.join('、')}。`
346
+ + `若这些是框架内部包(@deepseek-ai/*),该包不能作为插件安装(会导致服务启动失败);若为本应随包安装的依赖,请重试或检查网络。已取消注册。`)
347
+ }
348
+ await addBundleToManifest(profileDir, packageName)
349
+ job.bundle = true
350
+ } else {
351
+ job.bundleNote = `${packageName} 已由已安装条目提供,仅更新包本身,不再注册重复行`
352
+ }
353
+ } else if (served) {
354
+ mode = 'served'
355
+ job.note = `${packageName} 已由已安装条目提供,只更新包本身,不重复注册`
356
+ }
357
+ // ⑨ 收口:清单 + lock + 挂载行(唯一入口 domain/persist.js;结果 → 作业字段的唯一映射 install-finalize.js)
358
+ await finalizeInstallPersistence({
359
+ job, profileDir, patchPath, packageName, mode,
360
+ taken: new Set(entries.map((entry) => entry.rowId)), entries,
361
+ registries: orderedRegistries(readSources()),
362
+ source: { spec: null, origin: 'local-dir', kind: 'subdir' },
363
+ deps: deps.persistDeps ?? {},
364
+ })
365
+ // ⑩ 收口之后再看一眼**来源规格**:bundle 模式下的收尾补写可能把 link: 写成版本号(见 keepLinkSpec)
366
+ if (linkSpec !== null) await keepLinkSpec({ profileDir, packageName, spec: linkSpec, job })
367
+ job.status = 'done'
368
+ return true
369
+ }
370
+
371
+ export {
372
+ SUBDIR_SRC_DIR, SUBDIR_MAX_SEGMENTS,
373
+ normalizeSubdir, subdirSlug, verifySubdirManifest, acquireSubdirSource, placeSubdirSource, verifyPlacedManifest,
374
+ keepLinkSpec, runSubdirInstallJob,
375
+ }
@@ -134,7 +134,10 @@ async function shouldRunSuiteInstall(job, probes = {}) {
134
134
  }
135
135
  if (job.kind === 'skill') return decide(false, '技能请求不走套装通道')
136
136
  const gmProbe = typeof probes.probeGitmodules === 'function' ? probes.probeGitmodules : probeGitmodules
137
- const meta = await fetchRepoMeta(job.repo).catch(() => null)
137
+ // 默认分支探测也走 probes 袋子(2026-10-13,测试安全):离线测试注入桩即可一个请求都不发;
138
+ // 生产不注入 ⇒ 与改动前逐字相同(真 githubJson + curl 竞速 + 8s 降级)。
139
+ const metaProbe = typeof probes.fetchRepoMeta === 'function' ? probes.fetchRepoMeta : fetchRepoMeta
140
+ const meta = await metaProbe(job.repo).catch(() => null)
138
141
  job.repoMeta = meta
139
142
  const branch = meta?.default_branch ?? 'main'
140
143
  job.defaultBranch = branch
@@ -0,0 +1,63 @@
1
+ // L0 · infra —— cmd.exe 命令行拼装(0.5.48 从 exec.js 抽出:本文件贴着
2
+ // tests/test-architecture-guard.mjs 的"单文件 ≤ 600 行"硬顶;**只搬移,不改逻辑**)。
3
+ //
4
+ // 为什么 Windows 上要这么绕(2026-09-27 改错,桌面端实例"所有 pnpm 操作都跑不了"的两处根因之一):
5
+ // 旧代码是 `['corepack','pnpm',...args].map((a) => JSON.stringify(a)).join(' ')` —— JSON.stringify
6
+ // 加的是**真引号**,而这一整串又是作为一个 argv 元素交给 Node 的 spawn;Windows 上 Node/libuv 按
7
+ // MSVCRT 规则把参数里的 `"` 转义成 `\"`,cmd 实际收到 `\"corepack\" \"pnpm\"`
8
+ // → `'\"corepack\"' 不是内部或外部命令`。
9
+ // 本机对着一个只回显参数的 .cmd 实测三种形态:
10
+ // · `"a" "b"` + 默认转义 → 收到 `\"a\" \"b\"` → 失败(旧缺陷)
11
+ // · `"a" "b"` + windowsVerbatimArguments → `/s` 会把首尾两个引号一起剥掉 → 也失败
12
+ // · `""a" "b""` + windowsVerbatimArguments → 正确解析成两个参数 ✓
13
+ // 所以本模块产出的是"**内层**命令字符串"(形如 `corepack pnpm add x`,**永不含 `\"`**),
14
+ // 最外层那对引号由 cmdShellArgv 负责加。
15
+ //
16
+ // 已知边界(如实记,不假装覆盖):`%VAR%` 与开启延迟展开时的 `!VAR!` 是 cmd 自己的展开,
17
+ // **任何引号/`^` 都挡不住**;我们生成的 argv(包名、registry、布尔选项、绝对路径)不含这两个字符,
18
+ // 真出现时该通道会失败并如实报错(不会静默跑成别的命令)。
19
+ //
20
+ // 分层:L0 · infra(不得 import domain/routes/index —— tests/test-architecture-guard.mjs 断言②)。
21
+
22
+ /** cmd.exe 的元字符:`^` 是 cmd 自己的转义符(**引号内照样生效** —— 本机实测 `"a^b"` 到程序手里变成 `ab`),
23
+ * `& | < > ( )` 在引号内**仍然是 cmd 的操作符**(实测 `"a&b"` 会在 `&` 处断开,把 `b` 当命令执行)。
24
+ * 所以段内这些字符必须逐个加 `^` 转义。 */
25
+ const CMD_META_RE = /[\^&|<>()]/gu
26
+
27
+ /** 把一串命令段拼成**一条 cmd.exe 命令字符串**(纯函数,导出给单测)。
28
+ * 2026-09-28 改错(真机:`cmd-corepack` 一条都跑不动 → pnpm-runners / suite-fallback /
29
+ * install-smoke 三套红,报 `cmd-corepack :: status=1 out=""`):
30
+ * `corepack` 在 Windows 上常是 **cmd 批处理 shim**(`<node 安装目录>\corepack.cmd` →
31
+ * `node …\corepack\dist\corepack.js %*`)。对批处理,cmd **不会剥掉参数外层的引号**,
32
+ * 引号会随 `%*` 一路进到 node 的 argv —— 于是 corepack 收到的是 `"pnpm"` / `"--version"`
33
+ * (带引号当成了包管理器名)→ 直接崩:status=1、stdout 空。真 cmd.exe 实测:
34
+ * · `""corepack" "pnpm" "--version""` → status=1、stdout=""(旧形态,坏)
35
+ * · `"corepack pnpm --version"` → status=0、stdout="11.21.0"(按需引号,好)
36
+ * 所以**只为含空白的段加引号**;其余裸串 + `^` 转义 cmd 元字符。
37
+ * 只有「干净 token」(无空白、无 cmd 元字符)能去引号:含 `& | < > ( ) ^` 的段,实测
38
+ * **裸串 + `^` 转义会失效**(真机 ⑥:裸的 `a^&b` 被 cmd 当分隔符断开,收到 `[a`),
39
+ * 必须沿用「引号 + 段内 ^」的既有形态。 */
40
+ function cmdCommandLine(parts) {
41
+ return parts
42
+ .map((part) => String(part))
43
+ .map((text) => {
44
+ const clean = !/[\s]/u.test(text) && !/[\^&|<>()]/u.test(text)
45
+ if (clean) return text
46
+ return `"${text.replace(CMD_META_RE, (ch) => `^${ch}`)}"`
47
+ })
48
+ .join(' ')
49
+ }
50
+
51
+ /** corepack 形态的命令字符串(纯函数,导出给单测断言"不含 `\"` 转义")。 */
52
+ function cmdCorepackCommand(args) {
53
+ return cmdCommandLine(['corepack', 'pnpm', ...args])
54
+ }
55
+
56
+ /** 交给 cmd.exe 的完整 argv(`/d /s /c` + **整条再包一层引号**的内层命令字符串)。
57
+ * 必须与 `verbatim: true`(windowsVerbatimArguments)配套:Node 不再做任何引号转义,
58
+ * cmd 的 `/s` 剥掉最外层那一对,剩下的正好是我们要的命令行。 */
59
+ function cmdShellArgv(commandLine) {
60
+ return ['/d', '/s', '/c', `"${commandLine}"`]
61
+ }
62
+
63
+ export { CMD_META_RE, cmdCommandLine, cmdCorepackCommand, cmdShellArgv }