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

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,401 @@
1
+ // L1 · domain —— persist.js(「下载即持久」的**唯一判据** + **唯一收口动作**;0.5.38 加法)
2
+ //
3
+ // 用户诉求原话(2026-10-04):「能不能插件下载下来就不用 lock、重启也不会消失」。
4
+ // 现场(desktop profile 的 dshmarket):包确实下载了、node_modules 里有、bundle 行也挂着,
5
+ // 但 `dependencies` 里没有条目 ⇒ 之后任何一次 pnpm 操作都把它当多余包清掉,
6
+ // 而面板当时报的是「已安装并启用」——**装了没钉住**被报成了成功。
7
+ //
8
+ // 定案(本模块是唯一承担者,别处不许再各判一套):
9
+ // 「安装完成」= 三处齐备,缺一不可:
10
+ // ① `<profile>/package.json` 的 `dependencies` 里有该包,且 spec 与**磁盘真实形态**一致
11
+ // (`node_modules/<包名>` 是指向 plugin-src 的链接 → `link:`;真实目录 → 版本号;
12
+ // `file:`/`git+`/URL 来源 → 原样保留,绝不改写成版本号);
13
+ // ② `<profile>/pnpm-lock.yaml` 里有该包的条目,且与 ① 的 spec 同一形态
14
+ // (link: 依赖必须也钉在 link:,版本号必须逐字相等);
15
+ // ③ 重启后真的会被**挂载**:用户补丁里有它的 insert 行,或它已在 `dsh.profile.bundles` 里
16
+ // 且它自己声明了 `dsh.bundle.patch`(bundle 层会装入它自己的行),或运行时已有行提供它。
17
+ // 三者任一不成立 ⇒ `persisted:false` + `missing` 明细 + 结构化「一键钉住」动作 —— 面板**不许**报成功。
18
+ //
19
+ // 为什么判据必须集中在这一处(两处判据 = 下次框架/包管理器一变只有一处被修好):
20
+ // · 读文件的三个入口全部复用既有唯一真源:`profileSpec` / `readInstalledVersion` / `lockVersion`
21
+ // (domain/selfupdate.js)、`linkSpecIsIntact`(domain/dep-source.js)、`parseInsertNames`
22
+ // (domain/patch.js)——本模块**不新写任何 lock / 清单 / 补丁解析器**。
23
+ // · 写入同样复用既有写入器:`pinProfileDependency`(link: 形态)/ `declareProfileDependency`
24
+ // (registry 探测决定形态)/ `reconcileLockfile`(lock 对账)/ `appendInsert`(补丁行)/
25
+ // `addBundleToManifest`(bundle 层)——本模块**不新造第二套写入**。
26
+ //
27
+ // 非 registry 来源的既有规矩(照旧,绝不违反):`link:` / `file:` / `tgz` / git / URL 来源
28
+ // **不经** registry 解析 —— 因此本模块的判据里**没有**任何 "registry 404" 判定:
29
+ // ① 只比"清单写成什么形态 vs 磁盘是什么形态",② 只比"lock 里的条目 vs ① 的 spec 形态"。
30
+ //
31
+ // 分层:L1 domain —— 不认识 cordis ctx;IO 与四个写入器都可注入(便于离线单测跑正控/负控)。
32
+ import { existsSync, lstatSync, readFileSync, realpathSync } from 'node:fs'
33
+ import { join } from 'node:path'
34
+ import { PLUGIN_SRC_DIR, linkSpecFor, profileNameOf, linkSpecIsIntact, sameSourceSpec } from './dep-source.js'
35
+ import { isUnder } from '../infra/framework-root.js'
36
+ import { addBundleToManifest, declareProfileDependency, pinProfileDependency, setProfileDependency } from './manifest.js'
37
+ import { appendInsert, parseInsertNames } from './patch.js'
38
+ import { deriveEntryId } from './runtime.js'
39
+ import { lockVersion, profileSpec, readInstalledVersion, reconcileLockfile } from './selfupdate.js'
40
+ import { dshHome, packageNameOf } from '../infra/paths.js'
41
+
42
+ /** 三处缺口的**稳定名字**(面板文案与测试都按它们断言,不许改写)。 */
43
+ const PERSIST_PARTS = ['dependency', 'lock', 'mount']
44
+
45
+ /** 精确版本号形态(`1.2.3` / `0.0.1-rc1`):只有它才要求与磁盘版本逐字相等。 */
46
+ const EXACT_VERSION_RE = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/u
47
+ /** 来源型 spec 前缀(协议)——这些形态**绝不**改写成版本号(改写等于丢来源)。 */
48
+ const SOURCE_SPEC_RE = /^(?:link:|file:|git\+|git:|github:|https?:|workspace:|portal:)/u
49
+
50
+ /** 读 profile 清单(读不到返回 null;绝不因为读不到就抛)。 */
51
+ function readManifestSync(profileDir) {
52
+ try {
53
+ return JSON.parse(readFileSync(join(String(profileDir), 'package.json'), 'utf8'))
54
+ } catch {
55
+ return null
56
+ }
57
+ }
58
+
59
+ /** `dsh.profile.bundles`(读不到就空数组)。 */
60
+ function readBundlesSync(profileDir) {
61
+ const manifest = readManifestSync(profileDir)
62
+ const bundles = manifest?.dsh?.profile?.bundles
63
+ return Array.isArray(bundles) ? bundles.filter((x) => typeof x === 'string' && x !== '') : []
64
+ }
65
+
66
+ /**
67
+ * 磁盘**真实形态**(不猜):`node_modules/<包名>` 是指向别处的链接 → `link:<真实目标>`;
68
+ * 真实目录 → 版本号;都没有 → missing。
69
+ * Windows 的 junction 在 lstat 里同样是 symbolic link;`realpathSync` 拿到**规范化后的目标**
70
+ * (与 `linkSpecIsIntact` 的判据同一把尺子,因此"清单写的绝对路径"不必与磁盘逐字相同也算数)。
71
+ */
72
+ function diskFormOf(profileDir, packageName) {
73
+ const dir = join(String(profileDir), 'node_modules', ...String(packageName).split('/'))
74
+ const version = readInstalledVersion(profileDir, packageName)
75
+ if (!existsSync(join(dir, 'package.json'))) return { form: 'missing', spec: null, version, target: null }
76
+ try {
77
+ if (lstatSync(dir).isSymbolicLink()) {
78
+ const target = realpathSync(dir)
79
+ return { form: 'link', spec: linkSpecFor(target), version, target }
80
+ }
81
+ } catch {}
82
+ return { form: 'version', spec: version, version, target: null }
83
+ }
84
+
85
+ /** ① 依赖声明:清单里的 spec 与磁盘真实形态是否一致。 */
86
+ function dependencyPart({ profileDir, packageName }) {
87
+ const spec = profileSpec(profileDir, packageName)
88
+ const disk = diskFormOf(profileDir, packageName)
89
+ const out = { ok: false, spec, form: null, disk: { form: disk.form, version: disk.version }, reason: null }
90
+ if (spec === null) {
91
+ out.reason = 'package.json 的 dependencies 里没有这个条目(pnpm 会把它当多余包清掉)'
92
+ return out
93
+ }
94
+ const trimmed = spec.trim()
95
+ if (trimmed.startsWith('link:')) {
96
+ out.form = 'link'
97
+ if (disk.form !== 'link') {
98
+ out.reason = `清单是 ${trimmed},但 node_modules/${packageName} 不是指向它的链接(磁盘上实际是 ${disk.form === 'missing' ? '不存在' : '真实目录'})`
99
+ return out
100
+ }
101
+ if (!linkSpecIsIntact(profileDir, packageName, trimmed)) {
102
+ out.reason = `清单是 ${trimmed},但链接当前不成立(目标不存在或指向别处)`
103
+ return out
104
+ }
105
+ out.ok = true
106
+ return out
107
+ }
108
+ if (SOURCE_SPEC_RE.test(trimmed)) {
109
+ // file: / git+ / URL / workspace: …:来源即事实,本地无法逐字校验 ⇒ 有条目即算就位(绝不改写成版本号)
110
+ out.form = 'source'
111
+ out.ok = true
112
+ return out
113
+ }
114
+ if (EXACT_VERSION_RE.test(trimmed)) {
115
+ out.form = 'version'
116
+ if (disk.version === null) {
117
+ out.reason = '清单写的是版本号,但 node_modules 里读不到该包的版本(没装或被还原)'
118
+ return out
119
+ }
120
+ if (disk.version !== trimmed) {
121
+ out.reason = `清单写 ${trimmed},磁盘上是 ${disk.version} —— 两处对不上`
122
+ return out
123
+ }
124
+ out.ok = true
125
+ return out
126
+ }
127
+ // 版本范围(^1.2.3)或 dist-tag(latest/next):registry 来源但非精确值 —— 有条目且装上了即算就位
128
+ out.form = 'range'
129
+ if (disk.version === null) {
130
+ out.reason = `清单写 ${trimmed},但 node_modules 里读不到该包(没装或被还原)`
131
+ return out
132
+ }
133
+ out.ok = true
134
+ return out
135
+ }
136
+
137
+ /** ② lock 条目:与 ① 的 spec 同一形态(link: ↔ link:;精确版本 ↔ 逐字相等)。 */
138
+ function lockPart({ profileDir, packageName, spec }) {
139
+ const entry = lockVersion(profileDir, packageName)
140
+ const out = { ok: false, entry: entry ?? null, reason: null }
141
+ if (entry === null || entry === undefined) {
142
+ out.reason = 'pnpm-lock.yaml 里没有这个包的条目(任何一次 pnpm 操作都可能把它还原/清掉)'
143
+ return out
144
+ }
145
+ const value = String(entry).trim()
146
+ const s = typeof spec === 'string' ? spec.trim() : ''
147
+ if (s.startsWith('link:')) {
148
+ // 0.5.39 改错(Fix 2):link 条目还必须**指向清单里的同一个目标** —— 只判"以 link: 开头"会把
149
+ // 「清单指 A、lock 指 B」当成就位,而 pnpm 下一次操作会按 lock 把链接还原到 B(真实漂移)。
150
+ if (value.startsWith('link:') && sameSourceSpec(profileDir, value, s)) {
151
+ out.ok = true
152
+ return out
153
+ }
154
+ out.reason = value.startsWith('link:')
155
+ ? `清单把该包钉在 ${s},而 lock 里是 ${value}(**指向的目标不同**,对账后会被 pnpm 还原)`
156
+ : `清单把该包钉在 ${s},而 lock 里是 ${value}(对账后会被 pnpm 还原)`
157
+ return out
158
+ }
159
+ if (EXACT_VERSION_RE.test(s)) {
160
+ // lock 的 importer `version:` 对**带 peer 的**包会写成 `0.2.0-rc.2(@scope/peer@x)`(真机
161
+ // `@deepseek-ai/dsh-experimental-schedule-bundle` 就是这个形态)—— 前导版本号相等即算同版,
162
+ // 后缀是 pnpm 的 peer 解析记录,不是"另一个版本"。
163
+ const core = value.replace(/\(.*$/u, '').replace(/_[^_]*$/u, '').trim()
164
+ if (core === s || value === s) {
165
+ out.ok = true
166
+ return out
167
+ }
168
+ out.reason = `清单写 ${s},lock 里是 ${value} —— 两处对不上`
169
+ return out
170
+ }
171
+ // 范围 / dist-tag / 来源型:lock 有条目即算就位(形态由 pnpm 自己解析,本地不猜)
172
+ out.ok = true
173
+ return out
174
+ }
175
+
176
+ /** ③ 挂载行:用户补丁 insert 行 / bundle 层 / 运行时已有行,三者任一成立即算会挂载。 */
177
+ function mountPart({ profileDir, patchPath, packageName, patchText = null, bundles = null, entries = null }) {
178
+ const out = { ok: false, via: 'none', rowId: null, bundlePatch: null, reason: null }
179
+ // 运行时已有行提供它(例如聚合包里的行):最直接的证据,先看
180
+ if (Array.isArray(entries)) {
181
+ for (const entry of entries) {
182
+ const moduleName = String(entry?.moduleName ?? '')
183
+ if (moduleName === packageName || packageNameOf(moduleName) === packageName) {
184
+ return { ...out, ok: true, via: 'live-entry', rowId: entry.rowId ?? entry.entryId ?? null }
185
+ }
186
+ }
187
+ }
188
+ // 用户补丁里的 insert 行
189
+ let text = patchText
190
+ if (typeof text !== 'string') {
191
+ try {
192
+ text = existsSync(patchPath) ? readFileSync(patchPath, 'utf8') : ''
193
+ } catch {
194
+ text = ''
195
+ }
196
+ }
197
+ for (const [id, name] of parseInsertNames(text)) {
198
+ if (String(name).trim() === packageName) return { ...out, ok: true, via: 'patch-row', rowId: id }
199
+ }
200
+ // bundle 层:只有"它自己声明了 dsh.bundle.patch"时框架才会装入它自己的行
201
+ const list = Array.isArray(bundles) ? bundles : readBundlesSync(profileDir)
202
+ if (list.includes(packageName)) {
203
+ const pkgPath = join(String(profileDir), 'node_modules', ...String(packageName).split('/'), 'package.json')
204
+ let rel = null
205
+ try {
206
+ rel = JSON.parse(readFileSync(pkgPath, 'utf8'))?.dsh?.bundle?.patch ?? null
207
+ } catch {
208
+ rel = null
209
+ }
210
+ if (typeof rel !== 'string' || rel === '') {
211
+ out.reason = `已在 dsh.profile.bundles 里,但该包没有声明 dsh.bundle.patch —— bundle 层不会装入任何行,需要一条 insert 行`
212
+ return out
213
+ }
214
+ const file = join(String(profileDir), 'node_modules', ...String(packageName).split('/'), rel)
215
+ if (!existsSync(file)) {
216
+ out.bundlePatch = file
217
+ out.reason = `dsh.profile.bundles 里有它,但它声明的 bundle 补丁文件不存在(${file})`
218
+ return out
219
+ }
220
+ let rows = new Map()
221
+ try {
222
+ rows = parseInsertNames(readFileSync(file, 'utf8'))
223
+ } catch {
224
+ rows = new Map()
225
+ }
226
+ if (rows.size === 0) {
227
+ out.bundlePatch = file
228
+ out.reason = `bundle 补丁里读不到任何 insert 行(${rel})—— 没有任何东西会被挂载`
229
+ return out
230
+ }
231
+ return { ...out, ok: true, via: 'bundle', rowId: [...rows.keys()][0], bundlePatch: file }
232
+ }
233
+ out.reason = '用户补丁里没有它的 insert 行,dsh.profile.bundles 里也没有它 —— 重启后不会被挂载'
234
+ return out
235
+ }
236
+
237
+ /**
238
+ * 持久化判据(**只读**,永不抛):返回
239
+ * `{ persisted, packageName, missing, parts:{dependency,lock,mount}, note }`。
240
+ * 三个阶段各自读盘(不信任调用方传进来的 job 字段),missing 的顺序恒为 PERSIST_PARTS 的顺序。
241
+ */
242
+ function persistReport({ profileDir, patchPath, packageName, entries = null, patchText = null, bundles = null }) {
243
+ const dep = dependencyPart({ profileDir, packageName })
244
+ const lock = lockPart({ profileDir, packageName, spec: dep.spec })
245
+ const mount = mountPart({ profileDir, patchPath, packageName, patchText, bundles, entries })
246
+ const parts = { dependency: dep, lock, mount }
247
+ const missing = PERSIST_PARTS.filter((key) => parts[key].ok !== true)
248
+ const report = {
249
+ persisted: missing.length === 0,
250
+ packageName,
251
+ missing,
252
+ parts,
253
+ actual: {
254
+ spec: dep.spec,
255
+ lockEntry: lock.entry,
256
+ mountVia: mount.via,
257
+ version: dep.disk.version,
258
+ inBundles: (Array.isArray(bundles) ? bundles : readBundlesSync(profileDir)).includes(packageName),
259
+ },
260
+ }
261
+ report.note = report.persisted ? null : persistNote(report)
262
+ return report
263
+ }
264
+
265
+ /** 缺口 → 人话(**如实**:说清实际是什么、缺的是哪一处、后果是什么)。 */
266
+ function persistNote(report) {
267
+ const labels = { dependency: '① 清单 dependencies', lock: '② pnpm-lock.yaml', mount: '③ 挂载行(cordis.patch.yml / bundles)' }
268
+ const rows = report.missing.map((key) => `${labels[key]}:${report.parts[key].reason ?? '未就位'}`)
269
+ return `未持久化(重启或任何一次 pnpm 操作后可能消失)—— ${rows.join(';')}`
270
+ }
271
+
272
+ /** 结构化「一键钉住」动作(面板据此渲染执行框;命令只供展示/复制,执行侧只认 payload.action)。 */
273
+ function suggestedPersistAction({ packageName, profile = null, profileDir = null, missing = [], reason = null } = {}) {
274
+ const name = profile ?? profileNameOf(profileDir)
275
+ const list = Array.isArray(missing) ? missing.filter((m) => typeof m === 'string' && m !== '') : []
276
+ return {
277
+ kind: 'persist-plugin',
278
+ label: '一键钉住(清单 + lock + 挂载行)',
279
+ command: `dsh-plugin-console: persist-plugin ${packageName}`,
280
+ payload: { action: 'persist-plugin', packageName, profile: name },
281
+ missing: list,
282
+ reason,
283
+ }
284
+ }
285
+
286
+ /**
287
+ * 收口动作(**唯一的写入入口**):把 ①②③ 里缺的部分补齐,然后**读回磁盘**再判一次。
288
+ * · ① 缺/形态不一致 → 按磁盘真实形态写:链接 → `pinProfileDependency`(link: 并物化到 plugin-src);
289
+ * 真实目录 → `declareProfileDependency`(自带 registry 可解析性判定:非 registry 来源写 link:);
290
+ * · ② 缺/形态不一致 → `reconcileLockfile`(既有对账通道,argv/写回形态同安装路径);
291
+ * · ③ 缺 → `mode==='bundle'` 时补 `dsh.profile.bundles`;否则追加一条 insert 行(entry id 由
292
+ * `deriveEntryId` 按既有规则产出)。`mode==='served'` 时**绝不写行**(已有行提供它,再写就是重复装配)。
293
+ * 返回 `{ persisted, before, after, missing, declared, lock, notes, suggested, note, rowId }`;
294
+ * 任何一步失败都**如实**留在 notes 里,绝不影响收口的返回值语义(persisted 只认最终读回结果)。
295
+ */
296
+ async function ensurePersisted({ profileDir, patchPath, packageName, mode = 'insert', taken = null, entries = null, registries = [], deps = {} } = {}) {
297
+ const pin = typeof deps.pin === 'function' ? deps.pin : pinProfileDependency
298
+ const declare = typeof deps.declare === 'function' ? deps.declare : declareProfileDependency
299
+ const setSpec = typeof deps.setSpec === 'function' ? deps.setSpec : setProfileDependency
300
+ const reconcile = typeof deps.reconcile === 'function' ? deps.reconcile : reconcileLockfile
301
+ const insert = typeof deps.insert === 'function' ? deps.insert : appendInsert
302
+ const addBundle = typeof deps.addBundle === 'function' ? deps.addBundle : addBundleToManifest
303
+ const notes = []
304
+ const before = persistReport({ profileDir, patchPath, packageName, entries })
305
+ let declared = null
306
+ let lock = null
307
+ let rowId = before.parts.mount.rowId
308
+ // ① 依赖声明(按磁盘真实形态写,绝不猜)
309
+ if (before.parts.dependency.ok !== true) {
310
+ const disk = diskFormOf(profileDir, packageName)
311
+ try {
312
+ if (disk.form === 'link' && isUnder(disk.target, join(dshHome(), PLUGIN_SRC_DIR))) {
313
+ // 目标已经在 plugin-src 里 → 走既有「钉住」(幂等:realpath 相同则不会重复物化)
314
+ const pinned = await pin(profileDir, packageName, {})
315
+ declared = { changed: pinned.changed === true, spec: pinned.after ?? null, form: 'link', lockSynced: null, lockNote: null, depNote: null, suggested: null }
316
+ notes.push(pinned.changed === true
317
+ ? `清单已按磁盘真实形态钉成 ${pinned.after}(node_modules 里就是指到这个目录的链接)`
318
+ : `清单里已是 ${pinned.after ?? '(无条目)'}(无需改动)`)
319
+ } else if (disk.form === 'link') {
320
+ // 目标是用户自己的位置(开发目录 / 别的盘)→ **原样写这条链接**,绝不复制成 plugin-src 的新拷贝
321
+ const wrote = await setSpec(profileDir, packageName, disk.spec)
322
+ declared = { changed: wrote.changed === true, spec: wrote.after ?? disk.spec, form: 'link', lockSynced: null, lockNote: null, depNote: null, suggested: null }
323
+ notes.push(`清单已按磁盘真实值写成 ${wrote.after ?? disk.spec}(该链接指向用户自己的位置,未做物化复制)`)
324
+ } else if (disk.form === 'version') {
325
+ declared = await declare(profileDir, packageName, disk.version, { registries })
326
+ if (declared.changed !== true) notes.push(`清单里已有这个包(实际是 ${declared.spec ?? disk.version})`)
327
+ if (declared.depNote !== null && declared.depNote !== undefined) notes.push(declared.depNote)
328
+ } else {
329
+ notes.push(`node_modules 里读不到 ${packageName} —— 无法判断该写成什么形态,已跳过清单写回(绝不凭空猜一个 spec)`)
330
+ }
331
+ } catch (error) {
332
+ notes.push(`清单写回失败:${error instanceof Error ? error.message : String(error)}`)
333
+ }
334
+ }
335
+ // ② lock 对账
336
+ const mid = persistReport({ profileDir, patchPath, packageName, entries })
337
+ if (mid.parts.lock.ok !== true) {
338
+ try {
339
+ lock = await reconcile({ profileDir, packages: [{ name: packageName }], registries })
340
+ if (lock.lockNote !== null && lock.lockNote !== undefined) notes.push(lock.lockNote)
341
+ else if (lock.depNote !== null && lock.depNote !== undefined) notes.push(lock.depNote)
342
+ } catch (error) {
343
+ notes.push(`lock 对账失败:${error instanceof Error ? error.message : String(error)}`)
344
+ }
345
+ } else if (declared !== null && declared.lockSynced === false && declared.lockNote !== null) {
346
+ notes.push(declared.lockNote)
347
+ }
348
+ // ③ 挂载行
349
+ const pre = persistReport({ profileDir, patchPath, packageName, entries })
350
+ let bundled = null
351
+ if (pre.parts.mount.ok !== true && mode !== 'served') {
352
+ try {
353
+ if (mode === 'bundle') {
354
+ bundled = await addBundle(profileDir, packageName)
355
+ } else {
356
+ const id = deriveEntryId(packageName, taken instanceof Set ? taken : new Set())
357
+ const row = await insert(patchPath, id, packageName)
358
+ rowId = id
359
+ if (row && row.changed === false && row.reason !== undefined) {
360
+ notes.push(`未新写补丁行(原因:${row.reason})—— 由既有声明承担挂载`)
361
+ }
362
+ }
363
+ } catch (error) {
364
+ notes.push(`挂载行写入失败:${error instanceof Error ? error.message : String(error)}`)
365
+ }
366
+ }
367
+ const after = persistReport({ profileDir, patchPath, packageName, entries })
368
+ // 0.5.39 加法(修 1 的收尾):reconcile 写出 lock importer 条目后会用 pnpm 的**只读**用法确认 lock
369
+ // 仍可解析;「没通过 / 无法确认」时**不能**因为磁盘上有那两行就报成功 —— 按未持久化处理。
370
+ // 判据与文案都来自同一条通路(reconcile 的 lockVerified/lockNote),本模块只把结论并进来,不新判一套。
371
+ let lockVerdict = null
372
+ if (lock !== null && typeof lock === 'object' && (lock.lockVerified === false || lock.lockVerified === null) && lock.lockUpdated !== true) {
373
+ const reason = lock.lockNote ?? 'lock 已写入,但 pnpm 的只读校验未能确认它可解析'
374
+ after.parts.lock = { ...after.parts.lock, ok: false, reason }
375
+ after.missing = PERSIST_PARTS.filter((key) => after.parts[key].ok !== true)
376
+ after.persisted = after.missing.length === 0
377
+ after.note = after.persisted ? null : persistNote(after)
378
+ lockVerdict = { verified: lock.lockVerified, reason }
379
+ }
380
+ const suggested = after.persisted ? null : suggestedPersistAction({ packageName, profileDir, missing: after.missing, reason: after.note })
381
+ return {
382
+ persisted: after.persisted,
383
+ before,
384
+ after,
385
+ missing: after.missing,
386
+ parts: after.parts,
387
+ declared,
388
+ lock,
389
+ lockVerdict,
390
+ bundled,
391
+ rowId: after.parts.mount.rowId ?? rowId ?? null,
392
+ notes,
393
+ suggested,
394
+ note: after.note,
395
+ }
396
+ }
397
+
398
+ export {
399
+ PERSIST_PARTS, EXACT_VERSION_RE, SOURCE_SPEC_RE, readManifestSync, readBundlesSync, diskFormOf,
400
+ dependencyPart, lockPart, mountPart, persistReport, persistNote, suggestedPersistAction, ensurePersisted,
401
+ }
@@ -41,13 +41,14 @@ import { NPM_NAME_RE, suggestedAllowBuildsAction } from './dep-source.js'
41
41
  import { classifyInstallFailure, ignoredBuildsFrom } from './install-diagnose.js'
42
42
  import { runLockfileRepair } from './lockfile-health.js'
43
43
  import { pinProfileDependency, specOf } from './manifest.js'
44
+ import { ensurePersisted } from './persist.js'
44
45
  import { lockVersion } from './selfupdate.js'
45
46
  import { overwritePreset } from './preset-source.js'
46
47
  import { buildPnpmEnv, pnpmAddArgs, runPnpmWithFallback, unknownPnpmOption } from '../infra/exec.js'
47
48
 
48
49
  const DEFAULT_REGISTRY = 'https://registry.npmmirror.com'
49
50
  /** 动作白名单(客户端只能给这些 kind;执行分支与 argv 全在服务端)。 */
50
- const ACTION_KINDS = ['pin-dependency', 'reconcile-lock', 'allow-builds', 'overwrite-preset']
51
+ const ACTION_KINDS = ['pin-dependency', 'reconcile-lock', 'allow-builds', 'overwrite-preset', 'persist-plugin']
51
52
  /** 一旦出现这些键就整条拒绝:本接口不存在"传命令"的位置。 */
52
53
  const COMMAND_KEYS = ['command', 'cmd', 'argv', 'args', 'exec', 'execute', 'shell', 'script', 'run', 'spawn', 'bin', 'shellCommand']
53
54
  const OUTPUT_LIMIT = 4000
@@ -72,8 +73,8 @@ function parseActionRequest(body) {
72
73
  return { ok: false, status: 400, error: `未知动作 ${action}(只允许 ${ACTION_KINDS.join(' / ')})` }
73
74
  }
74
75
  const packageName = typeof raw.packageName === 'string' ? raw.packageName.trim() : ''
75
- if (action === 'pin-dependency') {
76
- if (packageName === '') return { ok: false, status: 400, error: 'pin-dependency 需要 packageName' }
76
+ if (action === 'pin-dependency' || action === 'persist-plugin') {
77
+ if (packageName === '') return { ok: false, status: 400, error: `${action} 需要 packageName` }
77
78
  if (packageName.length > 214 || !NPM_NAME_RE.test(packageName)) return { ok: false, status: 400, error: 'packageName 不是合法的 npm 包名' }
78
79
  }
79
80
  // allow-builds 的 packageName 可省略(缺省 = 放行当前被忽略的全部);给了就必须是合法 npm 名。
@@ -252,6 +253,44 @@ async function runSuggestedAction({ body, profileDir, registries = [], deps = {}
252
253
  detail: applied,
253
254
  }
254
255
  }
256
+ if (parsed.action === 'persist-plugin') {
257
+ // 0.5.38 加法(用户诉求:「插件下载下来就不用 lock、重启也不会消失」):把三处**一次补齐** ——
258
+ // ① 清单 spec(按磁盘真实形态:链接 → link:;真实目录 → 版本号;非 registry 来源照旧不猜)
259
+ // ② pnpm-lock.yaml 的条目(走既有对账通道)
260
+ // ③ 挂载行(已在 dsh.profile.bundles 的补 dsh.profile.bundles;否则追加 insert 行)
261
+ // 与 pin-dependency 的边界:pin 只动"来源形态"这一件事(历史行为一个字不改),本动作是**收口**,
262
+ // 且只在"确实缺"时才写;两者共用同一批写入器(patch.js / manifest.js / selfupdate.js),没有第二套。
263
+ if (!isProfilePackage(profileDir, parsed.packageName)) {
264
+ return { ...base, ok: false, status: 400, error: `${parsed.packageName} 不在本 profile 的清单(dependencies / dsh.profile.bundles)里,拒绝改动(先安装)` }
265
+ }
266
+ if (!isInstalled(profileDir, parsed.packageName)) {
267
+ return { ...base, ok: false, status: 400, error: `${parsed.packageName} 没有装在当前 profile 的 node_modules 里,无法钉住(先安装)` }
268
+ }
269
+ const ensure = typeof deps.ensurePersisted === 'function' ? deps.ensurePersisted : ensurePersisted
270
+ let fixed = null
271
+ try {
272
+ fixed = await ensure({ profileDir, patchPath: join(profileDir, 'cordis.patch.yml'), packageName: parsed.packageName, mode: 'insert', registries })
273
+ } catch (error) {
274
+ return { ...base, ok: false, status: 200, error: `持久化收口失败:${error instanceof Error ? error.message : String(error)}` }
275
+ }
276
+ const after = fixed?.after ?? null
277
+ return {
278
+ ...base,
279
+ ok: fixed?.persisted === true,
280
+ partial: fixed?.persisted !== true && (after?.parts?.dependency?.ok === true || after?.parts?.lock?.ok === true),
281
+ exitCode: fixed?.persisted === true ? 0 : 1,
282
+ command: `dsh-plugin-console: persist-plugin ${parsed.packageName}`,
283
+ manifest: { before: fixed?.before?.actual?.spec ?? null, after: after?.actual?.spec ?? null, changed: fixed?.declared?.changed === true, pinned: after?.parts?.dependency?.form === 'link' },
284
+ lock: { entry: after?.actual?.lockEntry ?? null, synced: after?.parts?.lock?.ok === true },
285
+ patch: { rowId: fixed?.rowId ?? null, via: after?.actual?.mountVia ?? null },
286
+ notes: Array.isArray(fixed?.notes) ? fixed.notes : [],
287
+ reason: fixed?.persisted === true
288
+ ? `${parsed.packageName} 已持久化:清单(${after?.actual?.spec ?? '?'})+ lock(${after?.actual?.lockEntry ?? '?'})+ 挂载(${after?.actual?.mountVia ?? '?'})`
289
+ : (fixed?.note ?? '仍有一处没就位'),
290
+ missing: Array.isArray(fixed?.missing) ? fixed.missing : [],
291
+ detail: { before: fixed?.before ?? null, after },
292
+ }
293
+ }
255
294
  if (parsed.action === 'reconcile-lock') {
256
295
  const view = await repair({ profileDir, registries: Array.isArray(registries) && registries.length > 0 ? registries : [registry] })
257
296
  const done = view?.action === 'repaired' || view?.action === 'noop'