@noob-stupid/dsh-plugin-console 0.5.39 → 0.5.41

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.
@@ -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
+ }
@@ -12,8 +12,11 @@
12
12
 
13
13
  import { readFile, writeFile } from 'node:fs/promises'
14
14
  import { join } from 'node:path'
15
- import { linkSpecFor, materializePackageForLink, probeFailureKind, probeRegistryPackage, sameSourceSpec, suggestedPinAction } from './dep-source.js'
15
+ import { canonicalSourceSpec, linkSpecFor, materializePackageForLink, probeFailureKind, probeRegistryPackage, sameSourceSpec, suggestedPinAction } from './dep-source.js'
16
+ import { ensureLinkSelfSufficiency, removeLinkSelfSufficiency } from './link-self-sufficiency.js'
17
+ import { composeSourceInstallNote, forgetSourceSpec, isLinkSourceSpec, isRealInstallSpec, recordSourceSpec, suggestedRealInstallAction } from './source-spec.js'
16
18
  import { queuedWrite } from '../infra/fsx.js'
19
+ import { runPnpmAdd } from '../infra/exec.js'
17
20
  import { resolvePackageJson } from '../infra/paths.js'
18
21
  import { reconcileLockfile } from './selfupdate.js'
19
22
 
@@ -108,10 +111,25 @@ async function specOf(profileDir, packageName) {
108
111
  * · 本次没探到(网络/镜像不可达) → 不敢替用户猜:仍按旧行为写版本号,但**如实记 note**
109
112
  * 并给出结构化「钉住」建议动作(面板可一键执行),绝不写成"已钉住"。
110
113
  * 返回 { form, spec, note, suggestion };`spec` 为 null 表示"不要改清单"。
114
+ *
115
+ * 2026-10-06 加法 —— **来源型默认【真装】**(用户点名:「默认真装 + link 自足」):
116
+ * 调用方**知道这个包的真实来源规格**时(tarball URL / `github:owner/repo` / 本地目录),
117
+ * 传 `sourceSpec` 进来,写回的就是**那个规格本身** —— 于是 `pnpm add <spec>` 由包管理器
118
+ * 保证依赖与 peer,与官方 `dsh plugin add` 同一条通道(`form: 'source'`)。
119
+ * `link:` 退居**显式开发式安装**:仅在"拿不到真实来源规格"(或用户明确选择开发式)时使用,
120
+ * 由 `link-self-sufficiency.js` 补齐依赖与框架 peer 垫片根。
121
+ * 优先级刻意保持"registry 可解析 → 版本号"在最前:既有 registry 流程一字不改(不倒退)。
111
122
  */
112
- async function planDependencySpec({ profileDir, packageName, version, registries = [], probe = null, home = undefined, profile = null }) {
123
+ async function planDependencySpec({ profileDir, packageName, version, registries = [], probe = null, home = undefined, profile = null, sourceSpec = null }) {
113
124
  const doProbe = typeof probe === 'function' ? probe : probeRegistryPackage
125
+ const realSpec = typeof sourceSpec === 'string' && sourceSpec.trim() !== '' ? sourceSpec.trim() : null
114
126
  const linkable = () => materializePackageForLink(profileDir, packageName, home === undefined ? {} : { home })
127
+ const asSource = () => ({
128
+ form: 'source',
129
+ spec: realSpec,
130
+ note: `${packageName}:按**真实来源规格**记录依赖(${realSpec})—— 走 pnpm add <spec> 真装,依赖与 peer 由包管理器保证(与官方 dsh plugin add 同一通道)`,
131
+ suggestion: null,
132
+ })
115
133
  const asLink = (why) => {
116
134
  const dir = linkable()
117
135
  if (dir === null) {
@@ -137,6 +155,8 @@ async function planDependencySpec({ profileDir, packageName, version, registries
137
155
  result = { resolvable: false, hasVersion: false, tried: [error instanceof Error ? error.message : String(error)] }
138
156
  }
139
157
  if (result?.resolvable === true && result.hasVersion !== false) return { form: 'version', spec: version, note: null, suggestion: null }
158
+ // 来源型:调用方给了真实来源规格 ⇒ 默认真装(不写 link:)。拿不到真实规格才退回 link:。
159
+ if (probeFailureKind(result) === 'fetch-404' && realSpec !== null) return asSource()
140
160
  if (probeFailureKind(result) === 'fetch-404') {
141
161
  return asLink(result?.resolvable === true
142
162
  ? `registry 上没有 ${version} 这个版本(该包只在 GitHub release / 本地有)`
@@ -164,13 +184,58 @@ async function planDependencySpec({ profileDir, packageName, version, registries
164
184
  * lockSynced: boolean, lockNote: string|null, depNote: string|null, suggested: object|null }}
165
185
  */
166
186
  async function declareProfileDependency(profileDir, packageName, version = null, options = {}) {
167
- const { syncLock = true, registries = [], probe = null, home = undefined, profile = null } = options
168
- const empty = { changed: false, version: null, spec: null, form: null, reason: null, lockSynced: false, lockNote: null, depNote: null, suggested: null }
187
+ const { syncLock = true, registries = [], probe = null, home = undefined, profile = null, sourceSpec = null, installSource = null, sourceOrigin = null, sourceKind = null } = options
188
+ const empty = { changed: false, version: null, spec: null, form: null, reason: null, lockSynced: false, lockNote: null, depNote: null, suggested: null, sourceInstall: null, selfSufficiency: null }
189
+ const realSpec = typeof sourceSpec === 'string' && sourceSpec.trim() !== '' ? sourceSpec.trim() : null
190
+ const homeOpt = home === undefined ? {} : { home }
191
+ // `link:` 规格 = **显式开发式安装**(本地目录的正常路径):**不**交给 pnpm 真装,
192
+ // 直接走既有 link: 路径 + 自足补齐(判据 C:本地目录不涉及下载,link: 就是它的正常形态)。
193
+ const linkSource = realSpec !== null && isLinkSourceSpec(realSpec)
194
+ const installSpec = realSpec !== null && linkSource !== true && isRealInstallSpec(realSpec) ? realSpec : null
195
+ // ★ 来源型默认【真装】(2026-10-06 加法):给了真实来源规格、而磁盘上还没有这个包时,
196
+ // **先真装**(pnpm add <spec>,依赖/peer 交给包管理器);装失败也**不拒绝** ——
197
+ // 如实记 sourceInstall 后继续走既有判定(退回 link: 并由自足性补齐)。
198
+ let sourceInstall = null
199
+ if (installSpec !== null && await installedVersionOf(profileDir, packageName) === null) {
200
+ sourceInstall = await installSourceDependency({ profileDir, spec: installSpec, registries, install: installSource })
201
+ recordSourceSpec({ packageName, spec: installSpec, kind: sourceKind, install: sourceInstall, ...homeOpt })
202
+ }
169
203
  const resolved = typeof version === 'string' && version !== '' ? version : await installedVersionOf(profileDir, packageName)
170
- if (resolved === null) return { ...empty, reason: '读不到已安装版本' }
204
+ if (resolved === null) {
205
+ // ★ 失败路径 B(取样/下载本身失败 ⇒ 物不在盘上):只有如实失败 + 原因,**绝不**出现
206
+ // "已装上 / 回落成功 / 已安装"之类说法(判据:回落只对"物已在盘上"成立)。
207
+ const reason = sourceInstall !== null && sourceInstall.ok !== true
208
+ ? `真装失败:${sourceInstall.error};磁盘上没有任何副本 —— 无可挂载(清单未改动)`
209
+ : '读不到已安装版本'
210
+ return { ...empty, sourceInstall, reason, depNote: composeSourceInstallNote({ packageName, install: sourceInstall }) ?? reason }
211
+ }
171
212
  // 探测/物化放在写锁**之外**(网络探测最长 8 秒,占着清单写锁会堵住其它写)
172
- const plan = await planDependencySpec({ profileDir, packageName, version: resolved, registries, probe, home, profile })
173
- if (plan.spec === null) return { ...empty, version: resolved, form: plan.form, reason: plan.note, depNote: plan.note, suggested: plan.suggestion }
213
+ let plan = linkSource
214
+ ? {
215
+ form: 'link',
216
+ spec: canonicalSourceSpec(realSpec),
217
+ note: `${packageName}:按**显式开发式安装**记录(${realSpec})—— 不经 npm registry 解析,依赖与框架 peer 由 link 自足性补齐`,
218
+ suggestion: null,
219
+ }
220
+ : await planDependencySpec({ profileDir, packageName, version: resolved, registries, probe, home, profile, sourceSpec: installSpec })
221
+ // ★ 本轮接线(2026-10-06):盘上那份是**取样通道**铺的(curl / GitHub release 资产 / 源码装配),
222
+ // 而不是包管理器装的 —— 而 registry 又查无此包(plan 判成来源型)⇒ **补一次真装**,
223
+ // 把依赖交给 pnpm(与官方 dsh plugin add 同一条通道)。
224
+ // 真装失败 ⇒ 回落既有 `link:` 路径:**回落只对"物已在盘上"成立**,这里正是(resolved 非空)。
225
+ if (installSpec !== null && sourceInstall === null && sourceOrigin === 'sampled' && plan.form === 'source') {
226
+ sourceInstall = await installSourceDependency({ profileDir, spec: installSpec, registries, install: installSource })
227
+ recordSourceSpec({ packageName, spec: installSpec, kind: sourceKind, install: sourceInstall, ...homeOpt })
228
+ if (sourceInstall.ok !== true) {
229
+ plan = await planDependencySpec({ profileDir, packageName, version: resolved, registries, probe, home, profile, sourceSpec: null })
230
+ }
231
+ }
232
+ const installNote = composeSourceInstallNote({ packageName, install: sourceInstall, plan })
233
+ if (plan.spec === null) {
234
+ return {
235
+ ...empty, version: resolved, form: plan.form, reason: plan.note, sourceInstall,
236
+ depNote: joinNotes(installNote, plan.note), suggested: plan.suggestion,
237
+ }
238
+ }
174
239
  const result = await queuedWrite(async () => {
175
240
  const manifest = await readManifest(profileDir)
176
241
  const deps = { ...(manifest.dependencies ?? {}) }
@@ -182,13 +247,56 @@ async function declareProfileDependency(profileDir, packageName, version = null,
182
247
  await writeManifest(profileDir, manifest)
183
248
  return { changed: true }
184
249
  })
185
- const out = { ...empty, changed: result.changed, version: resolved, spec: plan.spec, form: plan.form, depNote: plan.note, suggested: plan.suggestion }
250
+ // ★ link: 自足性(2026-10-06 加法):写成 link: 之后立刻补齐 —— 目标目录内装它的 dependencies
251
+ // + 建框架 peer 垫片根。幂等(自足 ⇒ 零写盘)、绝不覆盖它自带的 node_modules、
252
+ // 失败只如实报未就绪 + 给动作 —— 安装主路径**不新增拒绝分支**。
253
+ const selfSufficiency = plan.form === 'link'
254
+ ? await ensureLinkSelfSufficiency({ profileDir, packageName, spec: plan.spec, home })
255
+ : null
256
+ // 真装失败 ⇒ 结构化「一键真装」动作优先(此时"钉住/自足"类动作都不是用户想要的那一步);
257
+ // 它的 payload 里**没有** spec —— 规格由服务端从记录里读(见 domain/source-spec.js)。
258
+ const installAction = sourceInstall !== null && sourceInstall.ok !== true
259
+ ? suggestedRealInstallAction({ packageName, spec: sourceInstall.spec, profile, profileDir, reason: sourceInstall.error })
260
+ : null
261
+ const out = {
262
+ ...empty, changed: result.changed, version: resolved, spec: plan.spec, form: plan.form,
263
+ depNote: joinNotes(installNote, plan.note),
264
+ // 优先级:真装失败的「一键真装」> 自足性动作 > 计划里的建议(与 syncLock:false 的旧行为逐字一致)
265
+ suggested: installAction ?? (selfSufficiency === null ? null : selfSufficiency.action) ?? plan.suggestion,
266
+ sourceInstall, selfSufficiency,
267
+ }
186
268
  if (syncLock !== true) return out
187
269
  // **写完清单必须把 lock 拉齐**(2026-09-24 CI 实测):只写清单不写 lock → 清单/lock 不一致 →
188
270
  // 之后任何 pnpm 操作都会硬失败(ERR_PNPM_CANNOT_REMOVE_MISSING_DEPS)。
189
271
  // 放在写队列**之外**执行(对账本身会跑 pnpm,不该占着清单写锁);失败只记 note,不让安装失败。
190
- const synced = await syncLockFor(profileDir, [packageName], { registries, home, profile })
191
- return { ...out, ...synced, depNote: joinNotes(plan.note, synced.depNote), suggested: synced.suggested ?? plan.suggestion }
272
+ const synced = await syncLockFor(profileDir, [packageName], {
273
+ registries, home, profile,
274
+ // 0.5.41:刚真装出来的 **tarball URL** 是来源事实 ⇒ 告诉对账"别把它规整回 link:"
275
+ // (否则收口最后一步会把自己刚写下的真实来源规格改掉,用户看到的是"真装了但清单里是 link:")。
276
+ keepUrlSpecs: plan.form === 'source' && /^https?:/u.test(String(plan.spec ?? '')) ? [packageName] : [],
277
+ })
278
+ return {
279
+ ...out,
280
+ ...synced,
281
+ depNote: joinNotes(installNote, plan.note, selfSufficiency === null ? null : selfSufficiency.note, synced.depNote),
282
+ suggested: installAction ?? synced.suggested ?? (selfSufficiency === null ? null : selfSufficiency.action) ?? plan.suggestion,
283
+ }
284
+ }
285
+
286
+ /**
287
+ * 来源型**真装**(2026-10-06 加法):把真实来源规格交给包管理器 —— `pnpm add <spec>` 进 profile,
288
+ * 与官方 `dsh plugin add` 同一条通道(依赖与 peer 由 pnpm 保证,我们不手铺)。
289
+ * 返回 `{ ok, spec, error }`,**绝不抛**:安装主路径不得新增拒绝分支。
290
+ */
291
+ async function installSourceDependency({ profileDir, spec, registries = [], install = null, timeout = 180000 } = {}) {
292
+ const runner = typeof install === 'function' ? install : runPnpmAdd
293
+ const registry = (Array.isArray(registries) ? registries : []).find((r) => typeof r === 'string' && r.trim() !== '') ?? 'https://registry.npmmirror.com'
294
+ try {
295
+ await runner({ profileDir, spec, registry, timeout })
296
+ return { ok: true, spec, error: null }
297
+ } catch (error) {
298
+ return { ok: false, spec, error: String(error instanceof Error ? error.message : error).slice(0, 600) }
299
+ }
192
300
  }
193
301
 
194
302
  /** 多段说明拼接(空段自动跳过):文案只做拼接,不覆盖 —— 覆盖就是"谎报"的来源。 */
@@ -204,6 +312,7 @@ async function syncLockFor(profileDir, names, options = {}) {
204
312
  profileDir,
205
313
  packages: names.map((name) => ({ name })),
206
314
  registries: Array.isArray(options.registries) ? options.registries : [],
315
+ ...(Array.isArray(options.keepUrlSpecs) && options.keepUrlSpecs.length > 0 ? { keepUrlSpecs: options.keepUrlSpecs } : {}),
207
316
  ...(options.home === undefined ? {} : { home: options.home }),
208
317
  })
209
318
  return {
@@ -241,7 +350,10 @@ async function pinProfileDependency(profileDir, packageName, options = {}) {
241
350
  return true
242
351
  })
243
352
  const after = await specOf(profileDir, packageName)
244
- return { ok: sameSourceSpec(profileDir, after, spec), spec, before, after, changed, dir }
353
+ // ★ link: 自足性(2026-10-06 加法):钉住是"显式开发式安装"的入口,钉完立刻补齐
354
+ // (目标目录内装 dependencies + 框架 peer 垫片根)。幂等、绝不覆盖、失败只如实报。
355
+ const selfSufficiency = await ensureLinkSelfSufficiency({ profileDir, packageName, spec, home })
356
+ return { ok: sameSourceSpec(profileDir, after, spec), spec, before, after, changed, dir, selfSufficiency }
245
357
  }
246
358
 
247
359
  /**
@@ -266,9 +378,11 @@ async function setProfileDependency(profileDir, packageName, spec) {
266
378
  return { before, after: await specOf(profileDir, packageName), changed }
267
379
  }
268
380
 
269
- /** 卸载即撤销声明。 */
270
- async function undeclareProfileDependency(profileDir, packageName) {
271
- return queuedWrite(async () => {
381
+ /** 卸载即撤销声明(并清掉**我们造的** link 垫片 —— 卸载即净)。
382
+ * `options.home` 必须与当初 `declareProfileDependency` 用的 home 一致(生产两边都不传 = `dshHome()`);
383
+ * 只有隔离测试才会传私有 home —— 传错不会误删(记录读不到 ⇒ 无事发生),但也清不掉。 */
384
+ async function undeclareProfileDependency(profileDir, packageName, options = {}) {
385
+ const result = await queuedWrite(async () => {
272
386
  const manifest = await readManifest(profileDir)
273
387
  const deps = { ...(manifest.dependencies ?? {}) }
274
388
  if (!(packageName in deps)) return { changed: false }
@@ -277,6 +391,12 @@ async function undeclareProfileDependency(profileDir, packageName) {
277
391
  await writeManifest(profileDir, manifest)
278
392
  return { changed: true }
279
393
  })
394
+ // 2026-10-06 加法:link 自足性造的垫片是我们加的,卸载时只清**我们造的**那些
395
+ // (记录里逐个核对"现在仍是指向当初目标的链接",被人改过的一律不碰)。清不掉只如实报。
396
+ const shims = removeLinkSelfSufficiency({ packageName, home: options.home ?? null })
397
+ // 2026-10-06 接线:来源规格记录同样"卸载即撤销" —— 否则下一次安装会拿旧规格去真装(来源早已变了)。
398
+ const sourceRecord = forgetSourceSpec({ packageName, home: options.home ?? null })
399
+ return { ...result, shimsRemoved: shims.removed, shimsKept: shims.kept, selfSufficiencyNote: shims.note, sourceSpecForgotten: sourceRecord.removed || sourceRecord.emptied }
280
400
  }
281
401
 
282
402
  /**
@@ -330,6 +450,6 @@ async function applyDependencyBackfill(profileDir, options = {}) {
330
450
 
331
451
  export {
332
452
  DEFAULT_BUNDLES, addBundleToManifest, applyDependencyBackfill, declareProfileDependency,
333
- installedVersionOf, planDependencyBackfill, planDependencySpec, pinProfileDependency, removeBundleFromManifest,
453
+ installedVersionOf, installSourceDependency, planDependencyBackfill, planDependencySpec, pinProfileDependency, removeBundleFromManifest,
334
454
  setProfileDependency, specOf, syncLockFor, undeclareProfileDependency,
335
455
  }