@noob-stupid/dsh-plugin-console 0.5.39 → 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 +39 -1
- package/README.zh.md +27 -0
- package/lib/server/domain/link-self-sufficiency.js +417 -0
- package/lib/server/domain/manifest.js +75 -12
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -52,7 +52,7 @@ Then restart the dsh service → refresh the page → **Settings → Plugins →
|
|
|
52
52
|
[](https://www.npmjs.com/package/@noob-stupid/dsh-plugin-console)
|
|
53
53
|
[](https://www.npmjs.com/package/@noob-stupid/dsh-plugin-console)
|
|
54
54
|
[](https://github.com/Noob-stupid/dsh-plugin-gating-hub/releases)[](https://www.dsh.so/artifact/dsh-plugin-hub)
|
|
55
|
-
[](https://www.dsh.so/artifact/dsh-plugin-hub)
|
|
55
|
+
[](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 / 多源并行)——
|
|
@@ -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
|
+
}
|
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
import { readFile, writeFile } from 'node:fs/promises'
|
|
14
14
|
import { join } from 'node:path'
|
|
15
15
|
import { linkSpecFor, materializePackageForLink, probeFailureKind, probeRegistryPackage, sameSourceSpec, suggestedPinAction } from './dep-source.js'
|
|
16
|
+
import { ensureLinkSelfSufficiency, removeLinkSelfSufficiency } from './link-self-sufficiency.js'
|
|
16
17
|
import { queuedWrite } from '../infra/fsx.js'
|
|
18
|
+
import { runPnpmAdd } from '../infra/exec.js'
|
|
17
19
|
import { resolvePackageJson } from '../infra/paths.js'
|
|
18
20
|
import { reconcileLockfile } from './selfupdate.js'
|
|
19
21
|
|
|
@@ -108,10 +110,25 @@ async function specOf(profileDir, packageName) {
|
|
|
108
110
|
* · 本次没探到(网络/镜像不可达) → 不敢替用户猜:仍按旧行为写版本号,但**如实记 note**
|
|
109
111
|
* 并给出结构化「钉住」建议动作(面板可一键执行),绝不写成"已钉住"。
|
|
110
112
|
* 返回 { form, spec, note, suggestion };`spec` 为 null 表示"不要改清单"。
|
|
113
|
+
*
|
|
114
|
+
* 2026-10-06 加法 —— **来源型默认【真装】**(用户点名:「默认真装 + link 自足」):
|
|
115
|
+
* 调用方**知道这个包的真实来源规格**时(tarball URL / `github:owner/repo` / 本地目录),
|
|
116
|
+
* 传 `sourceSpec` 进来,写回的就是**那个规格本身** —— 于是 `pnpm add <spec>` 由包管理器
|
|
117
|
+
* 保证依赖与 peer,与官方 `dsh plugin add` 同一条通道(`form: 'source'`)。
|
|
118
|
+
* `link:` 退居**显式开发式安装**:仅在"拿不到真实来源规格"(或用户明确选择开发式)时使用,
|
|
119
|
+
* 由 `link-self-sufficiency.js` 补齐依赖与框架 peer 垫片根。
|
|
120
|
+
* 优先级刻意保持"registry 可解析 → 版本号"在最前:既有 registry 流程一字不改(不倒退)。
|
|
111
121
|
*/
|
|
112
|
-
async function planDependencySpec({ profileDir, packageName, version, registries = [], probe = null, home = undefined, profile = null }) {
|
|
122
|
+
async function planDependencySpec({ profileDir, packageName, version, registries = [], probe = null, home = undefined, profile = null, sourceSpec = null }) {
|
|
113
123
|
const doProbe = typeof probe === 'function' ? probe : probeRegistryPackage
|
|
124
|
+
const realSpec = typeof sourceSpec === 'string' && sourceSpec.trim() !== '' ? sourceSpec.trim() : null
|
|
114
125
|
const linkable = () => materializePackageForLink(profileDir, packageName, home === undefined ? {} : { home })
|
|
126
|
+
const asSource = () => ({
|
|
127
|
+
form: 'source',
|
|
128
|
+
spec: realSpec,
|
|
129
|
+
note: `${packageName}:按**真实来源规格**记录依赖(${realSpec})—— 走 pnpm add <spec> 真装,依赖与 peer 由包管理器保证(与官方 dsh plugin add 同一通道)`,
|
|
130
|
+
suggestion: null,
|
|
131
|
+
})
|
|
115
132
|
const asLink = (why) => {
|
|
116
133
|
const dir = linkable()
|
|
117
134
|
if (dir === null) {
|
|
@@ -137,6 +154,8 @@ async function planDependencySpec({ profileDir, packageName, version, registries
|
|
|
137
154
|
result = { resolvable: false, hasVersion: false, tried: [error instanceof Error ? error.message : String(error)] }
|
|
138
155
|
}
|
|
139
156
|
if (result?.resolvable === true && result.hasVersion !== false) return { form: 'version', spec: version, note: null, suggestion: null }
|
|
157
|
+
// 来源型:调用方给了真实来源规格 ⇒ 默认真装(不写 link:)。拿不到真实规格才退回 link:。
|
|
158
|
+
if (probeFailureKind(result) === 'fetch-404' && realSpec !== null) return asSource()
|
|
140
159
|
if (probeFailureKind(result) === 'fetch-404') {
|
|
141
160
|
return asLink(result?.resolvable === true
|
|
142
161
|
? `registry 上没有 ${version} 这个版本(该包只在 GitHub release / 本地有)`
|
|
@@ -164,12 +183,20 @@ async function planDependencySpec({ profileDir, packageName, version, registries
|
|
|
164
183
|
* lockSynced: boolean, lockNote: string|null, depNote: string|null, suggested: object|null }}
|
|
165
184
|
*/
|
|
166
185
|
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 }
|
|
186
|
+
const { syncLock = true, registries = [], probe = null, home = undefined, profile = null, sourceSpec = null, installSource = null } = options
|
|
187
|
+
const empty = { changed: false, version: null, spec: null, form: null, reason: null, lockSynced: false, lockNote: null, depNote: null, suggested: null, sourceInstall: null, selfSufficiency: null }
|
|
188
|
+
const realSpec = typeof sourceSpec === 'string' && sourceSpec.trim() !== '' ? sourceSpec.trim() : null
|
|
189
|
+
// ★ 来源型默认【真装】(2026-10-06 加法):给了真实来源规格、而磁盘上还没有这个包时,
|
|
190
|
+
// **先真装**(pnpm add <spec>,依赖/peer 交给包管理器);装失败也**不拒绝** ——
|
|
191
|
+
// 如实记 sourceInstall 后继续走既有判定(退回 link: 并由自足性补齐)。
|
|
192
|
+
let sourceInstall = null
|
|
193
|
+
if (realSpec !== null && await installedVersionOf(profileDir, packageName) === null) {
|
|
194
|
+
sourceInstall = await installSourceDependency({ profileDir, spec: realSpec, registries, install: installSource })
|
|
195
|
+
}
|
|
169
196
|
const resolved = typeof version === 'string' && version !== '' ? version : await installedVersionOf(profileDir, packageName)
|
|
170
|
-
if (resolved === null) return { ...empty, reason: '读不到已安装版本' }
|
|
197
|
+
if (resolved === null) return { ...empty, sourceInstall, reason: '读不到已安装版本' }
|
|
171
198
|
// 探测/物化放在写锁**之外**(网络探测最长 8 秒,占着清单写锁会堵住其它写)
|
|
172
|
-
const plan = await planDependencySpec({ profileDir, packageName, version: resolved, registries, probe, home, profile })
|
|
199
|
+
const plan = await planDependencySpec({ profileDir, packageName, version: resolved, registries, probe, home, profile, sourceSpec: realSpec })
|
|
173
200
|
if (plan.spec === null) return { ...empty, version: resolved, form: plan.form, reason: plan.note, depNote: plan.note, suggested: plan.suggestion }
|
|
174
201
|
const result = await queuedWrite(async () => {
|
|
175
202
|
const manifest = await readManifest(profileDir)
|
|
@@ -182,13 +209,40 @@ async function declareProfileDependency(profileDir, packageName, version = null,
|
|
|
182
209
|
await writeManifest(profileDir, manifest)
|
|
183
210
|
return { changed: true }
|
|
184
211
|
})
|
|
185
|
-
|
|
212
|
+
// ★ link: 自足性(2026-10-06 加法):写成 link: 之后立刻补齐 —— 目标目录内装它的 dependencies
|
|
213
|
+
// + 建框架 peer 垫片根。幂等(自足 ⇒ 零写盘)、绝不覆盖它自带的 node_modules、
|
|
214
|
+
// 失败只如实报未就绪 + 给动作 —— 安装主路径**不新增拒绝分支**。
|
|
215
|
+
const selfSufficiency = plan.form === 'link'
|
|
216
|
+
? await ensureLinkSelfSufficiency({ profileDir, packageName, spec: plan.spec, home })
|
|
217
|
+
: null
|
|
218
|
+
const out = { ...empty, changed: result.changed, version: resolved, spec: plan.spec, form: plan.form, depNote: plan.note, suggested: plan.suggestion, sourceInstall, selfSufficiency }
|
|
186
219
|
if (syncLock !== true) return out
|
|
187
220
|
// **写完清单必须把 lock 拉齐**(2026-09-24 CI 实测):只写清单不写 lock → 清单/lock 不一致 →
|
|
188
221
|
// 之后任何 pnpm 操作都会硬失败(ERR_PNPM_CANNOT_REMOVE_MISSING_DEPS)。
|
|
189
222
|
// 放在写队列**之外**执行(对账本身会跑 pnpm,不该占着清单写锁);失败只记 note,不让安装失败。
|
|
190
223
|
const synced = await syncLockFor(profileDir, [packageName], { registries, home, profile })
|
|
191
|
-
return {
|
|
224
|
+
return {
|
|
225
|
+
...out,
|
|
226
|
+
...synced,
|
|
227
|
+
depNote: joinNotes(plan.note, selfSufficiency === null ? null : selfSufficiency.note, synced.depNote),
|
|
228
|
+
suggested: synced.suggested ?? (selfSufficiency === null ? null : selfSufficiency.action) ?? plan.suggestion,
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* 来源型**真装**(2026-10-06 加法):把真实来源规格交给包管理器 —— `pnpm add <spec>` 进 profile,
|
|
234
|
+
* 与官方 `dsh plugin add` 同一条通道(依赖与 peer 由 pnpm 保证,我们不手铺)。
|
|
235
|
+
* 返回 `{ ok, spec, error }`,**绝不抛**:安装主路径不得新增拒绝分支。
|
|
236
|
+
*/
|
|
237
|
+
async function installSourceDependency({ profileDir, spec, registries = [], install = null, timeout = 180000 } = {}) {
|
|
238
|
+
const runner = typeof install === 'function' ? install : runPnpmAdd
|
|
239
|
+
const registry = (Array.isArray(registries) ? registries : []).find((r) => typeof r === 'string' && r.trim() !== '') ?? 'https://registry.npmmirror.com'
|
|
240
|
+
try {
|
|
241
|
+
await runner({ profileDir, spec, registry, timeout })
|
|
242
|
+
return { ok: true, spec, error: null }
|
|
243
|
+
} catch (error) {
|
|
244
|
+
return { ok: false, spec, error: String(error instanceof Error ? error.message : error).slice(0, 600) }
|
|
245
|
+
}
|
|
192
246
|
}
|
|
193
247
|
|
|
194
248
|
/** 多段说明拼接(空段自动跳过):文案只做拼接,不覆盖 —— 覆盖就是"谎报"的来源。 */
|
|
@@ -241,7 +295,10 @@ async function pinProfileDependency(profileDir, packageName, options = {}) {
|
|
|
241
295
|
return true
|
|
242
296
|
})
|
|
243
297
|
const after = await specOf(profileDir, packageName)
|
|
244
|
-
|
|
298
|
+
// ★ link: 自足性(2026-10-06 加法):钉住是"显式开发式安装"的入口,钉完立刻补齐
|
|
299
|
+
// (目标目录内装 dependencies + 框架 peer 垫片根)。幂等、绝不覆盖、失败只如实报。
|
|
300
|
+
const selfSufficiency = await ensureLinkSelfSufficiency({ profileDir, packageName, spec, home })
|
|
301
|
+
return { ok: sameSourceSpec(profileDir, after, spec), spec, before, after, changed, dir, selfSufficiency }
|
|
245
302
|
}
|
|
246
303
|
|
|
247
304
|
/**
|
|
@@ -266,9 +323,11 @@ async function setProfileDependency(profileDir, packageName, spec) {
|
|
|
266
323
|
return { before, after: await specOf(profileDir, packageName), changed }
|
|
267
324
|
}
|
|
268
325
|
|
|
269
|
-
/**
|
|
270
|
-
|
|
271
|
-
|
|
326
|
+
/** 卸载即撤销声明(并清掉**我们造的** link 垫片 —— 卸载即净)。
|
|
327
|
+
* `options.home` 必须与当初 `declareProfileDependency` 用的 home 一致(生产两边都不传 = `dshHome()`);
|
|
328
|
+
* 只有隔离测试才会传私有 home —— 传错不会误删(记录读不到 ⇒ 无事发生),但也清不掉。 */
|
|
329
|
+
async function undeclareProfileDependency(profileDir, packageName, options = {}) {
|
|
330
|
+
const result = await queuedWrite(async () => {
|
|
272
331
|
const manifest = await readManifest(profileDir)
|
|
273
332
|
const deps = { ...(manifest.dependencies ?? {}) }
|
|
274
333
|
if (!(packageName in deps)) return { changed: false }
|
|
@@ -277,6 +336,10 @@ async function undeclareProfileDependency(profileDir, packageName) {
|
|
|
277
336
|
await writeManifest(profileDir, manifest)
|
|
278
337
|
return { changed: true }
|
|
279
338
|
})
|
|
339
|
+
// 2026-10-06 加法:link 自足性造的垫片是我们加的,卸载时只清**我们造的**那些
|
|
340
|
+
// (记录里逐个核对"现在仍是指向当初目标的链接",被人改过的一律不碰)。清不掉只如实报。
|
|
341
|
+
const shims = removeLinkSelfSufficiency({ packageName, home: options.home ?? null })
|
|
342
|
+
return { ...result, shimsRemoved: shims.removed, shimsKept: shims.kept, selfSufficiencyNote: shims.note }
|
|
280
343
|
}
|
|
281
344
|
|
|
282
345
|
/**
|
|
@@ -330,6 +393,6 @@ async function applyDependencyBackfill(profileDir, options = {}) {
|
|
|
330
393
|
|
|
331
394
|
export {
|
|
332
395
|
DEFAULT_BUNDLES, addBundleToManifest, applyDependencyBackfill, declareProfileDependency,
|
|
333
|
-
installedVersionOf, planDependencyBackfill, planDependencySpec, pinProfileDependency, removeBundleFromManifest,
|
|
396
|
+
installedVersionOf, installSourceDependency, planDependencyBackfill, planDependencySpec, pinProfileDependency, removeBundleFromManifest,
|
|
334
397
|
setProfileDependency, specOf, syncLockFor, undeclareProfileDependency,
|
|
335
398
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@noob-stupid/dsh-plugin-console",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.40",
|
|
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",
|