@noob-stupid/dsh-plugin-console 0.5.38 → 0.5.40

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/README.md CHANGED
@@ -52,7 +52,7 @@ Then restart the dsh service → refresh the page → **Settings → Plugins →
52
52
  [![npm version](https://img.shields.io/npm/v/@noob-stupid/dsh-plugin-console?style=flat-square)](https://www.npmjs.com/package/@noob-stupid/dsh-plugin-console)
53
53
  [![npm downloads](https://img.shields.io/npm/dm/@noob-stupid/dsh-plugin-console?style=flat-square)](https://www.npmjs.com/package/@noob-stupid/dsh-plugin-console)
54
54
  [![GitHub Release](https://img.shields.io/github/v/release/Noob-stupid/dsh-plugin-gating-hub?style=flat-square)](https://github.com/Noob-stupid/dsh-plugin-gating-hub/releases)[![dsh.so security](https://www.dsh.so/badge/dsh-plugin-hub.svg)](https://www.dsh.so/artifact/dsh-plugin-hub)
55
- [![dsh.so install](https://www.dsh.so/badge/install/dsh-plugin-hub.svg)](https://www.dsh.so/artifact/dsh-plugin-hub)
55
+ [![dsh.so install · dsh 0.2.0-rc.1](https://www.dsh.so/badge/install/dsh-plugin-hub@0.2.0-rc.1.svg)](https://www.dsh.so/artifact/dsh-plugin-hub/)
56
56
 
57
57
  <img width="1170" height="609" alt="DSH Plugin Gating Hub" src="https://github.com/user-attachments/assets/b802d606-14ba-4151-9956-ff642ed12b0a" />
58
58
 
@@ -301,6 +301,44 @@ Environment reminder: an accelerator/proxy that rewrites hosts makes the direct
301
301
  (see above), and free mirrors are unreliable for the **git protocol** on large packs while
302
302
  **archive/raw are usually much faster** — the console already switches by itself.
303
303
 
304
+ #### Development installs (`link:`)
305
+
306
+ **Real install is the default.** Source-type packages (tarball URL / GitHub / local directory) hand
307
+ their **real source spec** to the package manager (`pnpm add <spec>`) — dependencies and peers are
308
+ guaranteed by pnpm, on the same channel as the official `dsh plugin add`.
309
+
310
+ `link:` is the separate, **explicit** route: mount a **local directory / plugin you are developing**
311
+ directly into the profile.
312
+
313
+ - **Source edits take effect immediately**: `node_modules/<name>` in the profile is a **link** to your
314
+ local directory, not a copy — edit the source, refresh the page. No reinstall, no repackaging.
315
+ - **Who it is for**: plugin authors, or anyone who wants to edit a plugin's source. If you just want
316
+ to *use* a plugin, the default real install is simpler.
317
+ - **What it pins**: the local artifact is written into the profile manifest `dependencies`
318
+ (`link:<absolute path>`) and gets a matching entry in `pnpm-lock.yaml` — so lockfile health checks
319
+ and rebuilds never treat it as an orphan, and it **survives restarts and instance updates**.
320
+ - **What the console completes for it** (otherwise the usual outcome is "installed, but it will not load"):
321
+ - **the target directory's own `dependencies`**: one install run inside that directory, filling in
322
+ what the local directory is missing;
323
+ - **the framework peer shim root**: when the local directory lives **outside** the profile tree,
324
+ Node's resolution chain never reaches the profile's `node_modules`. The console links the framework
325
+ peers it **declares and currently cannot resolve** (`@deepseek-ai/*`) at the **plugin root**,
326
+ pointing at `~/.dsh/profiles/node_modules/@deepseek-ai/<pkg>`. It is a **link**, not a copy —
327
+ both sides share the same realpath, so Cordis service classes and LLM error classes keep their
328
+ **module identity shared with the host**.
329
+ - **Boundaries** (always reported honestly, never faked):
330
+ - moving or deleting the local directory **breaks** the link (the manifest `link:` then points at a
331
+ path that no longer exists);
332
+ - shims only fill peers it **declares** and that are genuinely missing; a package the framework side
333
+ does not have is never invented;
334
+ - anything **already present** at the target path (including a dangling link) is **never overwritten**
335
+ — it is skipped and reported;
336
+ - **uninstall removes the shims the console created** (only its own; links you placed by hand are
337
+ never touched);
338
+ - if completion fails (target directory not writable, dependency install failed, …) the **install
339
+ still completes**, and the result honestly says "not ready + what is missing" together with an
340
+ action you can run.
341
+
304
342
  ### Static index market (plugin & skill tabs)
305
343
 
306
344
  > **Hybrid architecture**: browsing uses the static index (instant, zero GitHub API calls),
package/README.zh.md CHANGED
@@ -236,6 +236,33 @@ git clone https://github.com/Noob-stupid/dsh-plugin-gating-hub /tmp/dsh-plugin-c
236
236
  环境提醒:加速器/代理改 hosts 会让**直连源**失效(见上);免费镜像的 **git 协议**对大 pack 不可靠,
237
237
  而 **archive/raw 通常更快** —— 控制台已按这两条自行切换,不用手动换源。
238
238
 
239
+ #### 开发式安装(`link:`)
240
+
241
+ **默认是真装。** 来源型的包(tarball URL / GitHub / 本地目录)会把**真实来源规格**交给包管理器
242
+ (`pnpm add <spec>`)——依赖与 peer 由 pnpm 保证,与官方 `dsh plugin add` 同一条通道。
243
+
244
+ `link:` 是**显式**的另一条路:把**本地目录 / 正在开发的插件**直接挂进 profile。
245
+
246
+ - **改源码即时生效**:profile 里的 `node_modules/<包名>` 是一条指向本地目录的**链接**,不是副本 ——
247
+ 改完源码刷新页面即可,无需重装、无需重新打包。
248
+ - **适合谁**:插件作者,或想直接改某个插件源码的人。只想"装个插件来用"的话,默认的真装更省心。
249
+ - **它钉住什么**:本地那份物会被写进 profile 清单的 `dependencies`(`link:<绝对路径>`),并在
250
+ `pnpm-lock.yaml` 里留下对应条目 —— 所以「依赖锁体检 / 重建」不会把它当多余项清掉,
251
+ **重启与实例更新后依然在**。
252
+ - **控制台会为它自动补齐**(否则常见结果是"装好了却加载不起来"):
253
+ - **目标目录自己的 `dependencies`**:在这个目录里执行一次安装,把本地目录缺的依赖补上;
254
+ - **框架 peer 垫片根**:本地目录位于 profile 树**之外**时,Node 的解析链走不到 profile 的
255
+ `node_modules`。控制台会为它**声明过、且当前解析不到**的框架 peer(`@deepseek-ai/*`)
256
+ 在**插件根**建链接,指向 `~/.dsh/profiles/node_modules/@deepseek-ai/<包>`。
257
+ 是**链接**而非复制 —— 两侧 realpath 相同,Cordis 服务类 / LLM 错误类的**模块身份与宿主共享**。
258
+ - **边界**(都会如实报告,不会假装成功):
259
+ - 本地目录被**移动或删除** ⇒ 链接即断(清单里的 `link:` 指向已不存在的位置);
260
+ - 垫片**只补**它声明过、且当前确实缺失的 peer;框架侧本来就没有的包不会被凭空造出来;
261
+ - 目标位置上**已经有的任何东西**(含悬空链接)**绝不覆盖**,只跳过并说明;
262
+ - **卸载会清掉控制台造的垫片**(只清它自己造的那些;你手工放的链接一律不碰);
263
+ - 补齐失败(目标目录不可写、装依赖失败等)⇒ **安装照常完成**,结果里如实写"未就绪 + 缺什么",
264
+ 并给出可直接执行的动作。
265
+
239
266
  ### 静态索引市场(插件 / 技能双 tab)
240
267
 
241
268
  > **混合架构**:浏览走静态索引(秒开、零 API 调用),搜索走实时通道(GitHub 搜索 API / 多源并行)——
@@ -20,7 +20,7 @@
20
20
  // 不经 tarball 完整性校验,lock 删掉重建、node_modules 清空重装都稳定通过。
21
21
 
22
22
  import { existsSync, rmSync, mkdirSync, realpathSync } from 'node:fs'
23
- import { basename, dirname, join } from 'node:path'
23
+ import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
24
24
  import { copyTree } from '../infra/fsx.js'
25
25
  import { fetchJsonUrl } from '../infra/http.js'
26
26
  import { dshHome } from '../infra/paths.js'
@@ -113,6 +113,65 @@ function linkSpecFor(dir) {
113
113
  return `link:${String(dir).replace(/\\/gu, '/')}`
114
114
  }
115
115
 
116
+ /**
117
+ * 依赖规格的**唯一规范形**(0.5.39 加法):`link:` / `file:` 这类**路径型**来源统一成 pnpm 实际写出的
118
+ * 形态 —— **正斜杠**(是绝对还是相对保持调用方给的选择:pnpm 的 lock 里由它自己决定,见
119
+ * lock-importer.js#lockVersionText)。其它 spec(版本号/范围/dist-tag/URL/git)只做 trim。
120
+ *
121
+ * 为什么必须归一(真机证据,2026-10-04):desktop profile 的 `@noob-stupid/dsh-connection-card-host`
122
+ * 清单里是 `link:D:\dsh-link\dsh-connection-card-host`(**反斜杠**),而所有写回路径都是逐字比较
123
+ * (`pinProfileDependency` / `setProfileDependency` / `declareProfileDependency` 的 `deps[name] === spec`)
124
+ * ⇒ 任何一次「钉住」都会把它改成正斜杠 —— 用户没要求的漂移,而且幂等性彻底失效(每次都"有变化")。
125
+ */
126
+ function canonicalSourceSpec(spec) {
127
+ if (typeof spec !== 'string') return null
128
+ const s = spec.trim()
129
+ const m = /^(link:|file:)([\s\S]*)$/u.exec(s)
130
+ if (m === null) return s
131
+ let target = m[2].replace(/\\/gu, '/').replace(/\/{2,}/gu, '/')
132
+ // 去掉尾部多余的斜杠(但别把盘根 `C:/` 或 POSIX 根 `/` 也削掉)
133
+ if (target.length > 1 && /\/$/u.test(target) && !/:\/$/u.test(target)) target = target.replace(/\/+$/u, '')
134
+ return `${m[1]}${target}`
135
+ }
136
+
137
+ /** 路径型 spec 的目标目录(相对写法按 profile 目录解析);非路径型返回 null。 */
138
+ function sourceSpecTarget(profileDir, spec) {
139
+ const canonical = canonicalSourceSpec(spec)
140
+ if (canonical === null) return null
141
+ const m = /^(link:|file:)([\s\S]*)$/u.exec(canonical)
142
+ if (m === null || m[2] === '') return null
143
+ const raw = m[2]
144
+ try {
145
+ return isAbsolute(raw) ? raw : resolve(String(profileDir ?? ''), raw)
146
+ } catch {
147
+ return null
148
+ }
149
+ }
150
+
151
+ /**
152
+ * 两种写法是否**指向同一个东西**(0.5.39 加法,Fix 2 的判据):等价即视为**完好、零写盘**。
153
+ * · 先比规范形(`link:C:/x` ↔ `link:C:\x` ↔ `link:C:/x/` 全部等价);
154
+ * · 路径型再比 realpath(相对↔绝对、Windows 大小写差异、符号链接指向同一目录都算等价)。
155
+ * 只用它做"要不要写"的判断;真正写盘时一律写**规范形**(canonicalSourceSpec)。
156
+ */
157
+ function sameSourceSpec(profileDir, a, b) {
158
+ const na = canonicalSourceSpec(a)
159
+ const nb = canonicalSourceSpec(b)
160
+ if (na === null || nb === null) return false
161
+ if (na === nb) return true
162
+ if (!/^(?:link:|file:)/u.test(na) || !/^(?:link:|file:)/u.test(nb)) return false
163
+ const pa = sourceSpecTarget(profileDir, na)
164
+ const pb = sourceSpecTarget(profileDir, nb)
165
+ if (pa === null || pb === null) return false
166
+ try {
167
+ const ra = realpathSync(pa)
168
+ const rb = realpathSync(pb)
169
+ return process.platform === 'win32' ? ra.toLowerCase() === rb.toLowerCase() : ra === rb
170
+ } catch {
171
+ return false
172
+ }
173
+ }
174
+
116
175
  /**
117
176
  * manifest 里的 `link:` 规格当前是否**真的**还生效(`node_modules/<包名>` 就是指向它的那个链接)。
118
177
  * 为什么必须查:release / curl 通道更新包时是「先 rmSync 再 copyTree」,会把 `node_modules/<包名>`
@@ -198,4 +257,4 @@ function suggestedReconcileAction({ profileDir = null, profile = null, reason =
198
257
  }
199
258
  }
200
259
 
201
- export { PLUGIN_SRC_DIR, NPM_NAME_RE, probeRegistryPackage, probeFailureKind, materializePackageForLink, linkSpecFor, linkSpecIsIntact, profileNameOf, suggestedAllowBuildsAction, suggestedPinAction, suggestedReconcileAction }
260
+ export { PLUGIN_SRC_DIR, NPM_NAME_RE, probeRegistryPackage, probeFailureKind, materializePackageForLink, linkSpecFor, linkSpecIsIntact, profileNameOf, suggestedAllowBuildsAction, suggestedPinAction, suggestedReconcileAction, canonicalSourceSpec, sameSourceSpec, sourceSpecTarget }
@@ -0,0 +1,417 @@
1
+ // L1 · domain —— link-self-sufficiency.js(`link:` 依赖的**自足性**)
2
+ //
3
+ // 为什么需要它(2026-10-05/06 真机现场,ds-h-opencode-go 实测):
4
+ // `link:` 只把**一个目录**挂进 profile —— pnpm 建完那条链接就结束了。可一个插件要能被
5
+ // `import` 起来,还差两样东西,而这两样**没有任何人负责**:
6
+ // ① 目标目录自己的 `dependencies`(插件通常不发 node_modules,作者的开发目录里才有);
7
+ // ② 它声明过的**框架 peer**(`@deepseek-ai/*`)——目标目录在 profile 树**之外**时,
8
+ // Node 的解析链走不到 profile 的 node_modules,`import` 当场 `ERR_MODULE_NOT_FOUND`。
9
+ // 真机症状就是"面板报安装成功、插件却一直是 [no-fiber]":包在、链接在、就是加载不起来。
10
+ // 现场实测(插件目录 = D:\…\plugin-src\dsh-opencode-go):
11
+ // 修前 `import()` 失败于 `Cannot find package 'opencode-go-pi-ai'`
12
+ // (而 `opencode-go-pi-ai` 的实体**早就装好**在 `.pnpm` 里,只是顶层链接被 pnpm 挪进了
13
+ // `node_modules/.ignored` —— 所以"补齐"必须包含"把已有实体接回可达位置")。
14
+ //
15
+ // 三条硬规矩(本模块是它们的唯一承担者,别在别处再写一份判据):
16
+ // ① **只补缺的**:目标存在/可解析 ⇒ 一个字节都不写(幂等;自足插件是 no-op)。
17
+ // ② **绝不覆盖**:目标位置上**已经有任何东西**(含悬空链接)就跳过并如实记 skipped。
18
+ // ③ **绝不因此拒绝安装**:任何失败都返回 `{ ready:false, note, action }` 并让安装继续 ——
19
+ // 插件装上了但没就绪,是"如实报未就绪 + 给动作",不是"安装失败"。
20
+ //
21
+ // 垫片根的形状沿用生态既有约定(与 `docs/http-endpoints` 之外的现场一致):
22
+ // `<目标目录>/node_modules/@deepseek-ai/<包>` → `<DSH_HOME>/profiles/node_modules/@deepseek-ai/<包>`
23
+ // 为什么必须**链接**而不是复制:Cordis service 类 / LLM error 类等靠**模块身份**相认
24
+ // (`instanceof` / 单例注册表);复制一份就是第二个类对象,宿主与插件会互相不认。
25
+ // 链接指向的是同一份真实文件 ⇒ realpath 相同 ⇒ 身份共享(真机已验:
26
+ // `realpath(插件侧) === realpath(profiles/node_modules 侧)`)。
27
+ //
28
+ // 记录的用途:垫片是**我们造的**,卸载时必须只清掉**我们造的**这些(`removeLinkSelfSufficiency`),
29
+ // 记在 `<DSH_HOME>/plugin-console/link-self-sufficiency/<包名>.json`,删除前逐个核对
30
+ // "它现在仍是指向当初那个目标的链接"——被人改过/换过的东西一律不碰。
31
+
32
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, rmSync, rmdirSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
33
+ import { dirname, join } from 'node:path'
34
+ import { buildPnpmEnv, runPnpmWithFallback, unknownPnpmOption } from '../infra/exec.js'
35
+ import { dshHome } from '../infra/paths.js'
36
+ import { canonicalSourceSpec, sourceSpecTarget, suggestedPinAction } from './dep-source.js'
37
+
38
+ /** 框架包的 scope:垫片只补这一族(peer 声明里也只有它需要"框架根"这一层)。 */
39
+ const FRAMEWORK_SCOPE = '@deepseek-ai'
40
+
41
+ /** 垫片根(框架安装时建立的、profile 共用的解析根):`<DSH_HOME>/profiles/node_modules/@deepseek-ai`。 */
42
+ function frameworkShimRoot(home) {
43
+ return join(home, 'profiles', 'node_modules', FRAMEWORK_SCOPE)
44
+ }
45
+
46
+ /** 记录目录/记录文件(一个包一份,便于卸载时精确定位)。 */
47
+ function selfSufficiencyRecordDir(home) {
48
+ return join(home, 'plugin-console', 'link-self-sufficiency')
49
+ }
50
+ function recordFileNameOf(packageName) {
51
+ return `${String(packageName).replace(/[^A-Za-z0-9._-]+/gu, '_')}.json`
52
+ }
53
+ function selfSufficiencyRecordPath(home, packageName) {
54
+ return join(selfSufficiencyRecordDir(home), recordFileNameOf(packageName))
55
+ }
56
+
57
+ /** 读 JSON(读不到/坏文件都返回 null,绝不抛)。 */
58
+ function readJsonSafe(file) {
59
+ try {
60
+ return JSON.parse(readFileSync(file, 'utf8'))
61
+ } catch {
62
+ return null
63
+ }
64
+ }
65
+
66
+ /** 路径上**是否已经有东西**(含悬空链接)——"绝不覆盖"的判据必须用 lstat,不能用 exists。 */
67
+ function pathTaken(p) {
68
+ try {
69
+ lstatSync(p)
70
+ return true
71
+ } catch {
72
+ return false
73
+ }
74
+ }
75
+
76
+ /**
77
+ * 从 `startDir` 起按 Node 的规矩向上找 `node_modules/<包名>`(最多 12 层)。
78
+ * 为什么向上走而不是只看目标目录:目标目录若本身位于某个 profile 之内,
79
+ * 祖先链上的 node_modules **本来就能被 Node 解析到** —— 那时再补垫片就是多余写盘(违反规矩①)。
80
+ */
81
+ function resolveFromDir(startDir, packageName, depth = 12) {
82
+ let current = startDir
83
+ for (let i = 0; i < depth; i += 1) {
84
+ const candidate = join(current, 'node_modules', ...String(packageName).split('/'))
85
+ if (existsSync(join(candidate, 'package.json'))) return candidate
86
+ const parent = dirname(current)
87
+ if (parent === current) break
88
+ current = parent
89
+ }
90
+ return null
91
+ }
92
+
93
+ /** 读 profile 清单里该包的 spec(读不到返回 null)。 */
94
+ function manifestSpecOf(profileDir, packageName) {
95
+ const manifest = readJsonSafe(join(profileDir, 'package.json'))
96
+ const spec = manifest?.dependencies?.[packageName]
97
+ return typeof spec === 'string' && spec !== '' ? spec : null
98
+ }
99
+
100
+ /** 是否路径型来源(`link:` / `file:`)——只有这一族才有"目标目录"可补齐。 */
101
+ function isPathSpec(spec) {
102
+ const canonical = canonicalSourceSpec(spec)
103
+ return typeof canonical === 'string' && /^(?:link:|file:)/u.test(canonical)
104
+ }
105
+
106
+ /**
107
+ * 只读判据:这个 `link:` 依赖当前**自足**吗?缺什么?能不能补?
108
+ * 纯读盘、零写盘、不联网 —— 调用方拿它决定要不要动手(以及向用户怎么如实汇报)。
109
+ * 返回 `{ applies, ready, target, targetExists, declaredDeps, declaredPeers, missingDeps,
110
+ * missingPeers, shimmablePeers, unavailablePeers, note, action }`。
111
+ */
112
+ function planLinkSelfSufficiency({ profileDir, packageName, spec = null, home = null } = {}) {
113
+ const base = {
114
+ applies: false, ready: true, profileDir, packageName, spec: null, target: null, targetExists: false,
115
+ declaredDeps: [], declaredPeers: [], missingDeps: [], missingPeers: [], shimmablePeers: [], unavailablePeers: [], foreignPeers: [],
116
+ note: null, action: null,
117
+ }
118
+ const homeDir = typeof home === 'string' && home !== '' ? home : dshHome()
119
+ const resolvedSpec = typeof spec === 'string' && spec !== '' ? spec : manifestSpecOf(profileDir, packageName)
120
+ if (resolvedSpec === null) return { ...base, reason: `${packageName}:清单里没有该依赖(或 spec 为空)—— 无需补齐` }
121
+ if (!isPathSpec(resolvedSpec)) {
122
+ return { ...base, spec: resolvedSpec, reason: `${packageName}:spec 不是路径型来源(${resolvedSpec})—— 依赖与 peer 由包管理器保证,无需补齐` }
123
+ }
124
+ const target = sourceSpecTarget(profileDir, resolvedSpec)
125
+ if (target === null) return { ...base, spec: resolvedSpec, reason: `${packageName}:spec(${resolvedSpec})解析不出目标目录 —— 无需补齐` }
126
+ const pkg = readJsonSafe(join(target, 'package.json'))
127
+ if (pkg === null) {
128
+ return {
129
+ ...base,
130
+ applies: true,
131
+ ready: false,
132
+ spec: resolvedSpec,
133
+ target,
134
+ targetExists: false,
135
+ note: `${packageName}:链接目标 ${target} 里没有 package.json —— 本地目录被移动/删除,或还从未安装过;垫片无从补起(未改动任何东西)`,
136
+ action: suggestedPinAction({ packageName, profileDir }),
137
+ }
138
+ }
139
+ const declaredDeps = Object.keys(pkg.dependencies ?? {})
140
+ const declaredPeers = Object.keys(pkg.peerDependencies ?? {})
141
+ const missingDeps = declaredDeps.filter((name) => resolveFromDir(target, name) === null)
142
+ const missingPeers = declaredPeers.filter((name) => resolveFromDir(target, name) === null)
143
+ const root = frameworkShimRoot(homeDir)
144
+ const shimmablePeers = []
145
+ const unavailablePeers = []
146
+ for (const name of missingPeers) {
147
+ const short = name.startsWith(`${FRAMEWORK_SCOPE}/`) ? name.slice(FRAMEWORK_SCOPE.length + 1) : null
148
+ if (short !== null && existsSync(join(root, short, 'package.json'))) shimmablePeers.push(name)
149
+ else unavailablePeers.push(name)
150
+ }
151
+ // 「能解析」不等于「解析到宿主那一份」:声明过的框架 peer 若解析到**别处**(例如某次 pnpm install
152
+ // 从 registry 自动装进来的一份),模块身份就不共享。这里**只报告、不动手** —— 绝不覆盖是硬规矩。
153
+ const foreignPeers = []
154
+ for (const name of declaredPeers) {
155
+ if (missingPeers.includes(name)) continue
156
+ const short = name.startsWith(`${FRAMEWORK_SCOPE}/`) ? name.slice(FRAMEWORK_SCOPE.length + 1) : null
157
+ if (short === null) continue
158
+ const rootPkg = join(root, short)
159
+ if (!existsSync(join(rootPkg, 'package.json'))) continue
160
+ const found = resolveFromDir(target, name)
161
+ try {
162
+ if (found !== null && realpathSync(found) !== realpathSync(rootPkg)) foreignPeers.push({ name, resolved: found, frameworkRoot: rootPkg })
163
+ } catch {
164
+ foreignPeers.push({ name, resolved: found, frameworkRoot: rootPkg })
165
+ }
166
+ }
167
+ const ready = missingDeps.length === 0 && missingPeers.length === 0
168
+ return {
169
+ ...base,
170
+ applies: true,
171
+ ready,
172
+ spec: resolvedSpec,
173
+ target,
174
+ targetExists: true,
175
+ declaredDeps,
176
+ declaredPeers,
177
+ missingDeps,
178
+ missingPeers,
179
+ shimmablePeers,
180
+ unavailablePeers,
181
+ foreignPeers,
182
+ note: ready
183
+ ? null
184
+ : `${packageName}:链接目标 ${target} 尚不自足 —— 缺依赖 [${missingDeps.join(', ')}]` +
185
+ `${missingPeers.length === 0 ? '' : `、缺框架 peer [${missingPeers.join(', ')}]`}`,
186
+ action: ready ? null : suggestedPinAction({ packageName, profileDir }),
187
+ }
188
+ }
189
+
190
+ /** 在**目标目录内**装它的 dependencies(pnpm install)。失败一律如实返回,绝不抛。
191
+ *
192
+ * ★ 绝不让 pnpm **自动装 peer**(pnpm 8+ 默认 `auto-install-peers=true`)—— 真机实测(2026-10-06):
193
+ * 夹具声明 `@deepseek-ai/dsh-llm` 为 peer,跑 `pnpm install` 后 pnpm 从 **registry** 装了一份
194
+ * `0.1.5-rc.3` 进目标目录,而宿主用的是它**自己那一份**(本机框架根 = 0.2.0-rc.2)。
195
+ * 两份不同的模块 ⇒ Cordis service / LLM error 的**类身份不共享** —— 正是"补了反而更糟"。
196
+ * peer 的正确来源是**框架根垫片**(指向同一份真实文件),所以这里必须把自动装 peer 关掉,
197
+ * 分工才是干净的:**dependencies 交给 pnpm,peer 交给垫片**。
198
+ * 选项不认识(老 pnpm)时退回不带该选项重试一次 —— 与 infra/exec.js#runPnpmAdd 同一条规矩,
199
+ * 绝不让"加固"变成"跑不成"。 */
200
+ async function installTargetDependencies({ target, deps = {} } = {}) {
201
+ const run = typeof deps.runPnpm === 'function' ? deps.runPnpm : runPnpmWithFallback
202
+ const unknownOption = typeof deps.unknownOption === 'function' ? deps.unknownOption : unknownPnpmOption
203
+ const timeoutMs = Number.isFinite(deps.timeoutMs) ? deps.timeoutMs : 180000
204
+ const execOpts = { cwd: target, timeout: timeoutMs, windowsHide: true, maxBuffer: 4 * 1024 * 1024, env: buildPnpmEnv(null) }
205
+ const base = ['install', '--prefer-offline']
206
+ const attempts = [[...base, '--config.auto-install-peers=false'], base]
207
+ let lastError = null
208
+ for (const argv of attempts) {
209
+ try {
210
+ const out = await run(argv, { execOpts })
211
+ return { ok: true, error: null, argv, stdoutTail: String(out?.stdout ?? '').slice(-400) }
212
+ } catch (error) {
213
+ lastError = error
214
+ if (!unknownOption(String(error?.message ?? ''))) break
215
+ }
216
+ }
217
+ return {
218
+ ok: false,
219
+ error: String(lastError?.message ?? lastError).slice(0, 600),
220
+ argv: attempts[0],
221
+ exitCode: Number.isInteger(lastError?.code) ? lastError.code : null,
222
+ }
223
+ }
224
+
225
+ /** 建 peer 垫片(只补缺、绝不覆盖)。返回 `{ created, skipped }`。 */
226
+ function createPeerShims({ target, peers, home, deps = {} }) {
227
+ const symlink = typeof deps.symlink === 'function' ? deps.symlink : (t, p) => symlinkSync(t, p, 'junction')
228
+ const root = frameworkShimRoot(home)
229
+ const created = []
230
+ const skipped = []
231
+ for (const name of peers) {
232
+ const short = String(name).slice(FRAMEWORK_SCOPE.length + 1)
233
+ const linkPath = join(target, 'node_modules', FRAMEWORK_SCOPE, short)
234
+ const targetPath = join(root, short)
235
+ if (pathTaken(linkPath)) { skipped.push({ name, link: linkPath, reason: 'occupied(已存在任何东西,含悬空链接)—— 不覆盖' }); continue }
236
+ if (!existsSync(join(targetPath, 'package.json'))) { skipped.push({ name, reason: `框架根里没有 ${short}(无法补)` }); continue }
237
+ try {
238
+ mkdirSync(dirname(linkPath), { recursive: true })
239
+ symlink(targetPath, linkPath)
240
+ created.push({ name, link: linkPath, target: targetPath })
241
+ } catch (error) {
242
+ skipped.push({ name, reason: `建链接失败:${String(error?.message ?? error).slice(0, 200)}` })
243
+ }
244
+ }
245
+ return { created, skipped }
246
+ }
247
+
248
+ /** 写记录(与既有记录取并集,绝不丢掉早先造过的垫片 —— 否则卸载会漏清)。 */
249
+ function writeSelfSufficiencyRecord(home, packageName, created) {
250
+ if (created.length === 0) return null
251
+ const file = selfSufficiencyRecordPath(home, packageName)
252
+ const previous = readJsonSafe(file)
253
+ const known = Array.isArray(previous?.shims) ? previous.shims : []
254
+ const seen = new Set(known.map((s) => s?.link))
255
+ const merged = [...known]
256
+ for (const item of created) if (!seen.has(item.link)) merged.push({ name: item.name, link: item.link, target: item.target })
257
+ const record = { packageName, updatedAt: new Date().toISOString(), shims: merged }
258
+ try {
259
+ mkdirSync(dirname(file), { recursive: true })
260
+ writeFileSync(file, `${JSON.stringify(record, null, 2)}\n`, 'utf8')
261
+ return file
262
+ } catch {
263
+ return null
264
+ }
265
+ }
266
+
267
+ /** 这次尝试的人话小结(空段自动跳过;只拼接、不覆盖 —— 覆盖就是谎报的来源)。 */
268
+ function composeSelfSufficiencyNote({ packageName, installed, created, skipped, finalPlan }) {
269
+ const parts = []
270
+ if (installed !== null) {
271
+ parts.push(installed.ok === true
272
+ ? `${packageName}:已在链接目标内装齐 dependencies`
273
+ : `${packageName}:在链接目标内装 dependencies 失败(${installed.error})`)
274
+ }
275
+ if (created.length > 0) parts.push(`已补框架 peer 垫片 ${created.length} 个(${created.map((c) => c.name).join(', ')})`)
276
+ if (skipped.length > 0) parts.push(`跳过 ${skipped.length} 个(${skipped.map((s) => `${s.name}: ${s.reason}`).join(';')})`)
277
+ if ((finalPlan?.foreignPeers ?? []).length > 0) parts.push(`注意:${finalPlan.foreignPeers.map((p) => `${p.name} 解析到别处(${p.resolved}),与框架根不是同一份 ⇒ 模块身份可能与宿主不共享(未改动,仅如实提示)`).join(';')}`)
278
+ if (finalPlan?.ready === true) parts.push('链接目标现已自足')
279
+ else if (finalPlan?.applies === true) {
280
+ const missing = [...(finalPlan.missingDeps ?? []), ...(finalPlan.missingPeers ?? [])]
281
+ parts.push(`仍未就绪:缺 ${missing.join(', ') || '(目标目录不可用)'}`)
282
+ }
283
+ return parts.length === 0 ? null : parts.join(';')
284
+ }
285
+
286
+ /**
287
+ * 让一个 `link:` 依赖自足(**安装主路径上的唯一入口**)。
288
+ * 顺序是刻意的:**先装依赖、后建垫片** —— `pnpm install` 会按 lock 修剪 node_modules,
289
+ * 先建垫片会被它顺手清掉。
290
+ * 返回 `{ ok, applies, ready, changed, installed, created, skipped, note, action, plan }`。
291
+ * `ok` 表示"这次尝试本身没出意外",**不**表示插件一定可用;可用性看 `ready`。
292
+ * 任何情况都**不抛**:调用方(安装/钉住流程)绝不因为它而拒绝安装。
293
+ */
294
+ async function ensureLinkSelfSufficiency({ profileDir, packageName, spec = null, home = null, deps = {} } = {}) {
295
+ const homeDir = typeof home === 'string' && home !== '' ? home : dshHome()
296
+ const empty = { ok: true, applies: false, ready: true, changed: false, installed: null, created: [], skipped: [], note: null, action: null }
297
+ try {
298
+ const plan = planLinkSelfSufficiency({ profileDir, packageName, spec, home: homeDir })
299
+ if (plan.applies !== true) return { ...empty, note: plan.reason, plan }
300
+ if (plan.ready === true) return { ...empty, applies: true, note: null, plan } // 规矩①:自足 ⇒ 零写盘
301
+ if (plan.targetExists !== true) return { ...empty, applies: true, ready: false, note: plan.note, action: plan.action, plan }
302
+
303
+ let installed = null
304
+ if (plan.missingDeps.length > 0) {
305
+ installed = await installTargetDependencies({ target: plan.target, deps })
306
+ }
307
+ const afterDeps = planLinkSelfSufficiency({ profileDir, packageName, spec: plan.spec, home: homeDir })
308
+ const { created, skipped } = createPeerShims({ target: plan.target, peers: afterDeps.shimmablePeers, home: homeDir, deps })
309
+ if (created.length > 0) writeSelfSufficiencyRecord(homeDir, packageName, created)
310
+
311
+ const finalPlan = planLinkSelfSufficiency({ profileDir, packageName, spec: plan.spec, home: homeDir })
312
+ return {
313
+ ok: true,
314
+ applies: true,
315
+ ready: finalPlan.ready === true,
316
+ changed: created.length > 0 || installed?.ok === true,
317
+ installed,
318
+ created,
319
+ skipped,
320
+ note: composeSelfSufficiencyNote({ packageName, installed, created, skipped, finalPlan }),
321
+ action: finalPlan.ready === true ? null : finalPlan.action,
322
+ plan: finalPlan,
323
+ }
324
+ } catch (error) {
325
+ // 规矩③:意外也只如实报,绝不把它变成安装失败
326
+ return { ...empty, applies: true, ready: false, note: `${packageName}:补齐链接自足性时出错(${String(error?.message ?? error).slice(0, 300)})—— 已跳过,安装继续`, plan: null }
327
+ }
328
+ }
329
+
330
+ /** 列出全部自足性记录(报告/体检用;读不到就是空数组)。 */
331
+ function listSelfSufficiencyRecords({ home = null } = {}) {
332
+ const homeDir = typeof home === 'string' && home !== '' ? home : dshHome()
333
+ const dir = selfSufficiencyRecordDir(homeDir)
334
+ try {
335
+ return readdirSync(dir).filter((f) => f.endsWith('.json')).map((f) => ({ file: join(dir, f), record: readJsonSafe(join(dir, f)) }))
336
+ } catch {
337
+ return []
338
+ }
339
+ }
340
+
341
+ /**
342
+ * 删掉一个**链接本身**(不动它指向的目标)。
343
+ * ★ 这里踩过一次真坑(2026-10-06 本机实测):Windows 上 `fs.rmSync(junction, {recursive:false})`
344
+ * **一声不响地什么都不删**(不抛错、链接还在)—— 与 pnpm 改名被挡留下的 `*_tmp_*` 是同一族现场。
345
+ * 实测:`unlinkSync` 与 `rmdirSync` 都能删掉链接且目标存活;`rmSync` 两种写法都无效。
346
+ * 所以顺序是 unlink → rmdir,并逐个复核"真的没了",删不掉如实上报(绝不谎报已清理)。
347
+ */
348
+ function removeLinkEntry(link) {
349
+ for (const attempt of [() => unlinkSync(link), () => rmdirSync(link)]) {
350
+ try { attempt() } catch {}
351
+ if (!pathTaken(link)) return true
352
+ }
353
+ return !pathTaken(link)
354
+ }
355
+
356
+ /**
357
+ * 卸载时清掉**我们造的**垫片(卸载即撤销:与 `undeclareProfileDependency` 同一时机调用)。
358
+ * 只删"现在仍是指向当初记录目标的链接"的那些;被人改过/换成真实目录的一律保留并如实报。
359
+ * 返回 `{ removed, kept, note }`;不抛。
360
+ */
361
+ function removeLinkSelfSufficiency({ packageName, home = null } = {}) {
362
+ const homeDir = typeof home === 'string' && home !== '' ? home : dshHome()
363
+ const file = selfSufficiencyRecordPath(homeDir, packageName)
364
+ const record = readJsonSafe(file)
365
+ const shims = Array.isArray(record?.shims) ? record.shims : []
366
+ const removed = []
367
+ const kept = []
368
+ for (const item of shims) {
369
+ const link = typeof item?.link === 'string' ? item.link : null
370
+ const target = typeof item?.target === 'string' ? item.target : null
371
+ if (link === null || target === null || !pathTaken(link)) { kept.push({ link, reason: '链接已不在(可能已被用户/其它工具处理)' }); continue }
372
+ let stillOurs = false
373
+ try {
374
+ stillOurs = lstatSync(link).isSymbolicLink() && readlinkSync(link) === target
375
+ } catch {
376
+ stillOurs = false
377
+ }
378
+ if (!stillOurs) { kept.push({ link, reason: '它现在不是指向当初目标的链接(已被改动)—— 不碰' }); continue }
379
+ if (removeLinkEntry(link)) removed.push(link)
380
+ else kept.push({ link, reason: '删除未生效(可能被占用)—— 保留' })
381
+ }
382
+ let recordRemoved = false
383
+ let recordEmptied = false
384
+ if (existsSync(file)) {
385
+ try { rmSync(file, { force: true }) } catch {}
386
+ recordRemoved = !existsSync(file)
387
+ if (!recordRemoved) {
388
+ // 删不掉时**退而求其次清空内容**(真机实测:`<DSH_HOME>/plugin-console/` 下的文件在本机
389
+ // 删除会静默落空 —— 与 `*_tmp_*`、junction 是同一族的 Windows 静默失败)。
390
+ // 不能留下指向**已删垫片**的陈旧条目:下一次清理会"找不到"而误报,也让人误以为东西还在。
391
+ try {
392
+ writeFileSync(file, `${JSON.stringify({ packageName, updatedAt: new Date().toISOString(), shims: [], note: '垫片已清理;文件本身删除未生效,已清空以免留下陈旧条目' }, null, 2)}\n`, 'utf8')
393
+ recordEmptied = (readJsonSafe(file)?.shims ?? null)?.length === 0
394
+ } catch { recordEmptied = false }
395
+ }
396
+ }
397
+ const note = removed.length === 0 && kept.length === 0
398
+ ? null
399
+ : `垫片清理:删除 ${removed.length} 个${kept.length === 0 ? '' : `,保留 ${kept.length} 个(${kept.map((k) => `${k.link}:${k.reason}`).join(';')})`}` +
400
+ (recordRemoved || recordEmptied || !existsSync(file) ? '' : ';记录文件删除未生效且无法清空(已如实保留)')
401
+ return { removed, kept, recordRemoved, recordEmptied, note }
402
+ }
403
+
404
+ export {
405
+ FRAMEWORK_SCOPE,
406
+ frameworkShimRoot,
407
+ selfSufficiencyRecordDir,
408
+ selfSufficiencyRecordPath,
409
+ planLinkSelfSufficiency,
410
+ ensureLinkSelfSufficiency,
411
+ removeLinkSelfSufficiency,
412
+ listSelfSufficiencyRecords,
413
+ installTargetDependencies,
414
+ createPeerShims,
415
+ resolveFromDir,
416
+ isPathSpec,
417
+ }