@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.
package/README.md CHANGED
@@ -52,7 +52,7 @@ Then restart the dsh service → refresh the page → **Settings → Plugins →
52
52
  [![npm version](https://img.shields.io/npm/v/@noob-stupid/dsh-plugin-console?style=flat-square)](https://www.npmjs.com/package/@noob-stupid/dsh-plugin-console)
53
53
  [![npm downloads](https://img.shields.io/npm/dm/@noob-stupid/dsh-plugin-console?style=flat-square)](https://www.npmjs.com/package/@noob-stupid/dsh-plugin-console)
54
54
  [![GitHub Release](https://img.shields.io/github/v/release/Noob-stupid/dsh-plugin-gating-hub?style=flat-square)](https://github.com/Noob-stupid/dsh-plugin-gating-hub/releases)[![dsh.so security](https://www.dsh.so/badge/dsh-plugin-hub.svg)](https://www.dsh.so/artifact/dsh-plugin-hub)
55
- [![dsh.so install](https://www.dsh.so/badge/install/dsh-plugin-hub.svg)](https://www.dsh.so/artifact/dsh-plugin-hub)
55
+ [![dsh.so install · dsh 0.2.0-rc.1](https://www.dsh.so/badge/install/dsh-plugin-hub@0.2.0-rc.1.svg)](https://www.dsh.so/artifact/dsh-plugin-hub/)
56
56
 
57
57
  <img width="1170" height="609" alt="DSH Plugin Gating Hub" src="https://github.com/user-attachments/assets/b802d606-14ba-4151-9956-ff642ed12b0a" />
58
58
 
@@ -301,6 +301,42 @@ 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
+ Ordinary installs go through the package manager (`pnpm add`); that is the baseline, not a feature here.
307
+
308
+ `link:` is the separate, **explicit** route: mount a **local directory / plugin you are developing**
309
+ directly into the profile.
310
+
311
+ - **Source edits take effect immediately**: `node_modules/<name>` in the profile is a **link** to your
312
+ local directory, not a copy — edit the source, refresh the page. No reinstall, no repackaging.
313
+ - **Who it is for**: plugin authors, or anyone who wants to edit a plugin's source. If you just want
314
+ to *use* a plugin, an ordinary install is simpler.
315
+ - **What it pins**: the local artifact is written into the profile manifest `dependencies`
316
+ (`link:<absolute path>`) and gets a matching entry in `pnpm-lock.yaml` — so lockfile health checks
317
+ and rebuilds never treat it as an orphan, and it **survives restarts and instance updates**.
318
+ - **What the console completes for it** (otherwise the usual outcome is "installed, but it will not load"):
319
+ - **the target directory's own `dependencies`**: one install run inside that directory, filling in
320
+ what the local directory is missing;
321
+ - **the framework peer shim root**: when the local directory lives **outside** the profile tree,
322
+ Node's resolution chain never reaches the profile's `node_modules`. The console links the framework
323
+ peers it **declares and currently cannot resolve** (`@deepseek-ai/*`) at the **plugin root**,
324
+ pointing at `~/.dsh/profiles/node_modules/@deepseek-ai/<pkg>`. It is a **link**, not a copy —
325
+ both sides share the same realpath, so Cordis service classes and LLM error classes keep their
326
+ **module identity shared with the host**.
327
+ - **Boundaries** (always reported honestly, never faked):
328
+ - moving or deleting the local directory **breaks** the link (the manifest `link:` then points at a
329
+ path that no longer exists);
330
+ - shims only fill peers it **declares** and that are genuinely missing; a package the framework side
331
+ does not have is never invented;
332
+ - anything **already present** at the target path (including a dangling link) is **never overwritten**
333
+ — it is skipped and reported;
334
+ - **uninstall removes the shims the console created** (only its own; links you placed by hand are
335
+ never touched);
336
+ - if completion fails (target directory not writable, dependency install failed, …) the **install
337
+ still completes**, and the result honestly says "not ready + what is missing" together with an
338
+ action you can run.
339
+
304
340
  ### Static index market (plugin & skill tabs)
305
341
 
306
342
  > **Hybrid architecture**: browsing uses the static index (instant, zero GitHub API calls),
package/README.zh.md CHANGED
@@ -236,6 +236,32 @@ 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
+ 普通安装走包管理器(`pnpm add`)—— 那是基线,不是这里的卖点。
242
+
243
+ `link:` 是**显式**的另一条路:把**本地目录 / 正在开发的插件**直接挂进 profile。
244
+
245
+ - **改源码即时生效**:profile 里的 `node_modules/<包名>` 是一条指向本地目录的**链接**,不是副本 ——
246
+ 改完源码刷新页面即可,无需重装、无需重新打包。
247
+ - **适合谁**:插件作者,或想直接改某个插件源码的人。只想"装个插件来用"的话,普通安装更省心。
248
+ - **它钉住什么**:本地那份物会被写进 profile 清单的 `dependencies`(`link:<绝对路径>`),并在
249
+ `pnpm-lock.yaml` 里留下对应条目 —— 所以「依赖锁体检 / 重建」不会把它当多余项清掉,
250
+ **重启与实例更新后依然在**。
251
+ - **控制台会为它自动补齐**(否则常见结果是"装好了却加载不起来"):
252
+ - **目标目录自己的 `dependencies`**:在这个目录里执行一次安装,把本地目录缺的依赖补上;
253
+ - **框架 peer 垫片根**:本地目录位于 profile 树**之外**时,Node 的解析链走不到 profile 的
254
+ `node_modules`。控制台会为它**声明过、且当前解析不到**的框架 peer(`@deepseek-ai/*`)
255
+ 在**插件根**建链接,指向 `~/.dsh/profiles/node_modules/@deepseek-ai/<包>`。
256
+ 是**链接**而非复制 —— 两侧 realpath 相同,Cordis 服务类 / LLM 错误类的**模块身份与宿主共享**。
257
+ - **边界**(都会如实报告,不会假装成功):
258
+ - 本地目录被**移动或删除** ⇒ 链接即断(清单里的 `link:` 指向已不存在的位置);
259
+ - 垫片**只补**它声明过、且当前确实缺失的 peer;框架侧本来就没有的包不会被凭空造出来;
260
+ - 目标位置上**已经有的任何东西**(含悬空链接)**绝不覆盖**,只跳过并说明;
261
+ - **卸载会清掉控制台造的垫片**(只清它自己造的那些;你手工放的链接一律不碰);
262
+ - 补齐失败(目标目录不可写、装依赖失败等)⇒ **安装照常完成**,结果里如实写"未就绪 + 缺什么",
263
+ 并给出可直接执行的动作。
264
+
239
265
  ### 静态索引市场(插件 / 技能双 tab)
240
266
 
241
267
  > **混合架构**:浏览走静态索引(秒开、零 API 调用),搜索走实时通道(GitHub 搜索 API / 多源并行)——
@@ -0,0 +1,45 @@
1
+ // L1 · domain —— install-finalize.js(安装收口结果 → 作业字段的**唯一映射**;2026-10-06 从 install-job.js 搬出)
2
+ //
3
+ // 为什么单独成模块:install-job.js 贴着 600 行架构棘轮(tests/test-architecture-guard.mjs),而这块
4
+ // 本来就是自洽的一块 —— 收口(domain/persist.js)返回什么,作业(`installJobView` 下发给面板)就怎么显示,
5
+ // 中间**不许**再各判一套。搬移只搬移未改逻辑;0.5.41 只多一条:**真装失败 ⇒ 一键真装动作优先**
6
+ // (link 回落让插件能加载,但用户要的那一步"依赖交给 pnpm"没成,必须能一键补上;三处齐备时也要下发)。
7
+ //
8
+ // `deps` 是**离线测试缝**(与 marketProbes/sourceDeps 同一风格):正控/负控必须能确定性地让 lock 那一步
9
+ // 失败("故意让 lock 写入失败 ⇒ 必须报未持久化而不是成功"),生产调用方不传时形状与默认值一个字都不变。
10
+
11
+ import { ensurePersisted } from './persist.js'
12
+ import { sourcePersistOptions } from './source-spec.js'
13
+
14
+ /**
15
+ * 收口 + 落到作业字段。返回 `ensurePersisted` 的完整结果(调用方按需再读)。
16
+ * 只写这些字段(与旧代码逐字一致):`job.depNote` / `job.declared` / `job.entryId` / `job.persisted` /
17
+ * `job.persist` / `job.persistNote` / `job.suggestedAction`。
18
+ */
19
+ async function finalizeInstallPersistence({
20
+ job, profileDir, patchPath, packageName, mode, taken = null, entries = null, registries = [],
21
+ source = null, deps = {},
22
+ }) {
23
+ const persist = await ensurePersisted({
24
+ profileDir, patchPath, packageName, mode, taken, entries, registries, deps,
25
+ ...sourcePersistOptions(source),
26
+ })
27
+ const persistNotes = [persist.declared?.depNote, persist.declared?.lockNote, persist.lock?.lockNote, persist.lock?.depNote, ...persist.notes]
28
+ .filter((n) => typeof n === 'string' && n !== '')
29
+ if (persistNotes.length > 0) job.depNote = job.depNote === undefined ? persistNotes.join(';') : `${job.depNote};${persistNotes.join(';')}`
30
+ if (persist.declared?.changed === true) job.declared = persist.declared.spec
31
+ // entryId 的语义**与旧行为一致**:只有"用户补丁里的 insert 行"才算本作业注册的行(bundle 层提供的行
32
+ // 与运行时已有行都不算,旧代码在这两条路上也不设 entryId)——撤销安装的归属判定依赖这一点。
33
+ job.entryId = persist.after.parts.mount.via === 'patch-row' ? (persist.rowId ?? null) : null
34
+ job.persisted = persist.persisted
35
+ job.persist = { missing: persist.missing, actual: persist.after.actual, note: persist.note }
36
+ if (persist.persisted !== true) job.persistNote = persist.note
37
+ const persistSuggested = persist.suggested ?? null
38
+ if (persistSuggested !== null
39
+ && (persistSuggested.kind === 'real-install' || job.suggestedAction === undefined || job.suggestedAction === null)) {
40
+ job.suggestedAction = persistSuggested
41
+ }
42
+ return persist
43
+ }
44
+
45
+ export { finalizeInstallPersistence }
@@ -18,7 +18,7 @@ import { tryPresetSourceChannel } from './preset-source.js'
18
18
  import { settlePresetOutcome } from './preset-in-package.js'
19
19
  import { alreadyInstalledTarget, verifyInstalledEntry } from './install-verify.js'
20
20
  import { applyNpmNameHint, expandSubpackageCandidates, fetchRepoPackage, fetchRepoPackageEx, fetchSubpackageNames, npmNameHintForRepo, packageProbeErrorText, subpackageCandidates } from './market.js'
21
- import { ensurePersisted } from './persist.js'
21
+ import { finalizeInstallPersistence } from './install-finalize.js'
22
22
  import { readPatchState, syncNameFromNote } from './patch.js'
23
23
  import { listEntries } from './runtime.js'
24
24
  import { detectSkillRepo, runSkillInstallJob } from './skills.js'
@@ -75,6 +75,10 @@ async function tryCandidateChannels({ job, ch, name, profileDir, registries, rep
75
75
  const patchPath = join(profileDir, 'cordis.patch.yml')
76
76
  let installedName = null
77
77
  let lastError = null
78
+ // 0.5.41 接线:这次成功装上的**来源**(`{ spec, origin, kind }`)—— 取样通道(curl tarball /
79
+ // GitHub release 资产)知道真实下载地址,收口那一步据此走 `pnpm add <spec>` 真装(依赖交给 pnpm)。
80
+ // registry 通道不填(既有流程一字不改);git 通道也不填(它本来就是 pnpm 装的,清单里已是 pnpm 写的规格)。
81
+ let source = null
78
82
  // 加法优化:包已在 node_modules 且名字匹配时,不再重复安装/触发 EPERM,直接进入启用流程
79
83
  // (2026-09-27 搬进 install-verify.js#alreadyInstalledTarget:本文件贴着 600 行架构硬顶)
80
84
  const already = alreadyInstalledTarget(profileDir, name, job)
@@ -100,6 +104,9 @@ async function tryCandidateChannels({ job, ch, name, profileDir, registries, rep
100
104
  }
101
105
  if (racedInfo.boxNote) job.curlNote += `(盒子验证:${racedInfo.boxNote})`
102
106
  job.integrity = racedInfo.integrity ?? null // ④ 下载物摘要校验结果(加法;见 install-verify.js)
107
+ // 0.5.41:curl tarball 是**取样通道**(物是解压铺进去的,不是包管理器装的)——
108
+ // 真实来源规格(registry tarball URL)带回收口。registry 可解析时收口仍写版本号(不倒退)。
109
+ source = { spec: racedInfo.sourceSpec ?? null, origin: 'sampled', kind: 'registry-tarball' }
103
110
  }
104
111
  }
105
112
  } catch (raceError) {
@@ -126,6 +133,8 @@ async function tryCandidateChannels({ job, ch, name, profileDir, registries, rep
126
133
  job.curlNote = `已通过 curl 通道安装 v${info.version}${stillMissing.length > 0 ? `(依赖仍未补齐:${stillMissing.join('、')},网络恢复后建议重新安装)` : '(捆绑依赖已补齐)'}`
127
134
  if (info.boxNote) job.curlNote += `(盒子验证:${info.boxNote})`
128
135
  job.integrity = info.integrity ?? null // ④ 下载物摘要校验结果(加法;见 install-verify.js)
136
+ // 0.5.41:同上(取样通道 ⇒ 带真实 tarball URL 回去真装)
137
+ source = { spec: info.sourceSpec ?? null, origin: 'sampled', kind: 'registry-tarball' }
129
138
  } catch (curlError) {
130
139
  lastError = curlError
131
140
  }
@@ -144,6 +153,9 @@ async function tryCandidateChannels({ job, ch, name, profileDir, registries, rep
144
153
  const from = typeof info.sourceNote === 'string' && info.sourceNote !== '' ? `(来源:${info.sourceNote})` : ''
145
154
  job.curlNote = `已通过 GitHub release 通道安装 v${info.version ?? '?'}${from}${stillMissing.length > 0 ? `(依赖仍未补齐:${stillMissing.join('、')},网络恢复后建议重新安装)` : '(捆绑依赖已补齐)'}`
146
155
  if (info.boxNote) job.curlNote += `(盒子验证:${info.boxNote})`
156
+ // 0.5.41 接线(**本轮的核心**):GitHub release 资产(tarball URL)是取样通道 —— 把真实下载
157
+ // 地址带回给收口,由 `pnpm add <url>` 真装(依赖与 peer 交给包管理器,与官方 dsh plugin add 同效)。
158
+ source = { spec: info.sourceSpec ?? null, origin: 'sampled', kind: 'github-release' }
147
159
  } catch (ghError) {
148
160
  lastError = ghError
149
161
  }
@@ -186,7 +198,7 @@ async function tryCandidateChannels({ job, ch, name, profileDir, registries, rep
186
198
  installedName = retry.installedName ?? installedName
187
199
  if (retry.error != null) lastError = retry.error
188
200
  }
189
- if (installedName !== null) return { installedName, lastError }
201
+ if (installedName !== null) return { installedName, lastError, source }
190
202
  // Windows 原子替换失败(陈旧目录 / _tmp_ 残留)是 EPERM 类错误的根因:
191
203
  // 清理后用主源重试一次(把 AI 人工修复经验自动化,减少 AI 兜底触发)
192
204
  if (/EPERM|EACCES|rename/i.test(String(lastError?.message ?? ''))) {
@@ -356,6 +368,8 @@ async function runInstallJob(job, ports, deps = {}) {
356
368
  for (const id of [...patch.inserts, ...patch.disables, ...patch.forced]) taken.add(id)
357
369
  let installedName = null
358
370
  let lastError = null
371
+ // 0.5.41:成功后由哪个**来源**装上的(`{ spec, origin, kind } | null`)—— 一路传到收口那一步
372
+ let installedSource = null
359
373
  let expanded = false
360
374
  // 可配置软件源:主→备依次尝试(默认 npmmirror → npmjs,可增删自定义/内网源)
361
375
  const registries = orderedRegistries(readSources())
@@ -401,7 +415,7 @@ async function runInstallJob(job, ports, deps = {}) {
401
415
  })
402
416
  installedName = attempt.installedName
403
417
  lastError = attempt.lastError
404
- if (installedName !== null) break
418
+ if (installedName !== null) { installedSource = attempt.source ?? null; break }
405
419
  }
406
420
  // 预设收口(D-③ 源码装配成功 / D-⑥ 落地物里带预设):能按预设收口就直接 return —— 见 preset-in-package.js
407
421
  // (这两条路都**没有 node_modules 插件**,写补丁行/声明依赖会让补丁行指向无入口模块 → 启动崩溃)。
@@ -430,7 +444,13 @@ async function runInstallJob(job, ports, deps = {}) {
430
444
  // 不能让用户面对"面板说失败、磁盘上却留了半个包和 _tmp_ 残留"的糊涂账。
431
445
  const leftovers = cleanupAttemptedCandidates(profileDir, candidates)
432
446
  job.leftovers = leftovers
447
+ // ★ 失败路径 B 的文案(0.5.41 加法):**只有如实失败 + 原因**。取样/下载本身失败时磁盘上
448
+ // 什么都没有 ⇒ 没有任何东西可挂 ⇒ 面板**绝不**出现"回落成功 / 已安装 / 已启用"之类说法。
449
+ // 这里把最后一次的真实失败原因接在既有文案之后(不改写、不掩盖),并保留既有诊断分类(可重试动作
450
+ // 由面板的「重试」按钮与 diagnosis.hint 承担)。
451
+ const reason = typeof lastError?.message === 'string' && lastError.message !== '' ? lastError.message : null
433
452
  job.error = aiConsentFailureText(decision, leftovers, aiConsentTimeoutMs)
453
+ + (reason === null ? '' : `;最后一次失败原因:${reason}`)
434
454
  }
435
455
  return
436
456
  }
@@ -469,24 +489,28 @@ async function runInstallJob(job, ports, deps = {}) {
469
489
  } catch {}
470
490
  // lock 对账(2026-09-21,用户报告的自更新缺陷同源):主包 + 本次被补装/对齐的聚合子包一起核对,
471
491
  // 一次 pnpm add 把漂移的包全写进 lock;对不上就留 lockNote,让面板如实告知(不假装成功)。
492
+ // ★ 0.5.41:本次装上的包**有真实来源规格**时(取样通道 = curl/release 资产),这里的对账要**让开**:
493
+ // 它只会按"registry 查无此包"的老判据写成 link:(还顺手复制进 plugin-src、留一句"已按 link: 形式记录"),
494
+ // 而紧接着的收口会用真实来源规格(`pnpm add <spec>` 真装)把它声明对 —— 两处不再打架。
472
495
  try {
473
496
  // 同一个离线测试缝(见下方 `deps.persistDeps` 注释):安装路径上"动 lock"的两处都归它,
474
497
  // 否则负控里这一处会先偷偷把 lock 写齐,收口那一步就再也测不出"未持久化"。
475
498
  const reconcile = typeof deps.persistDeps?.reconcile === 'function' ? deps.persistDeps.reconcile : reconcileLockfile
476
- const lock = await reconcile({
477
- profileDir,
478
- packages: [installedName, ...syncedNames].map((name) => ({ name })),
479
- registries,
480
- })
481
- job.lockUpdated = lock.lockUpdated
482
- job.lockVersion = lock.lockVersion
483
- job.lockMethod = lock.method
484
- job.lockPackages = lock.packages
485
- if (lock.lockNote !== null) job.lockNote = lock.lockNote
486
- // 非常规来源(link:)写回说明:必须让用户看见(缺陷②最阴的地方就是"装完完全看不出问题")
487
- if (lock.depNote !== null) job.depNote = lock.depNote
488
- // 写回没生效时的**结构化**建议动作(加法;老客户端忽略该字段,向后兼容)
489
- if (Array.isArray(lock.suggestedActions) && lock.suggestedActions.length > 0) job.suggestedAction = lock.suggestedActions[0]
499
+ const lockPackages = [installedName, ...syncedNames]
500
+ .filter((name) => !(installedSource !== null && typeof installedSource.spec === 'string' && installedSource.spec !== '' && name === installedName))
501
+ .map((name) => ({ name }))
502
+ if (lockPackages.length > 0) {
503
+ const lock = await reconcile({ profileDir, packages: lockPackages, registries })
504
+ job.lockUpdated = lock.lockUpdated
505
+ job.lockVersion = lock.lockVersion
506
+ job.lockMethod = lock.method
507
+ job.lockPackages = lock.packages
508
+ if (lock.lockNote !== null) job.lockNote = lock.lockNote
509
+ // 非常规来源(link:)写回说明:必须让用户看见(缺陷②最阴的地方就是"装完完全看不出问题")
510
+ if (lock.depNote !== null) job.depNote = lock.depNote
511
+ // 写回没生效时的**结构化**建议动作(加法;老客户端忽略该字段,向后兼容)
512
+ if (Array.isArray(lock.suggestedActions) && lock.suggestedActions.length > 0) job.suggestedAction = lock.suggestedActions[0]
513
+ }
490
514
  } catch {}
491
515
  job.stage = 'configuring'
492
516
  // 2026-09-06 事故(i18n 更新变重复行):@linxin666/dsh-i18n 声明了 dsh.bundle.patch,
@@ -495,11 +519,11 @@ async function runInstallJob(job, ports, deps = {}) {
495
519
  const alreadyServed = listEntries(ports).some((e) => String(e.moduleName).startsWith(installedName)
496
520
  || String(e.moduleName) === installedName
497
521
  || packageNameOf(e.moduleName) === installedName)
498
- // 安装收口(0.5.38):**一次动作内**把该插件钉住 —— ① 清单 spec ② pnpm-lock.yaml 条目
499
- // ③ 挂载行(cordis.patch.yml 的 insert 行 / dsh.profile.bundles 的 bundle 层)。
500
- // 三处齐备才算 `persisted`;缺任何一处都**如实**报「未持久化」+ 一键钉住动作 —— 绝不报成功
501
- // (用户真机现场:dshmarket 装了、bundle 行挂着,清单里却没有条目 ⇒ 重启/任何 pnpm 操作即消失)。
502
- // 判据与写入的唯一承担者是 domain/persist.js(别处不许再各判一套)。
522
+ // 安装收口(0.5.38 判据 / 0.5.41 接线):**一次动作内**把该插件钉住 —— ① 清单 spec
523
+ // ② pnpm-lock.yaml 条目 ③ 挂载行(cordis.patch.yml 的 insert 行 / dsh.profile.bundles 的 bundle 层)。
524
+ // 判据与写入的唯一承担者是 domain/persist.js;结果 → 作业字段的唯一映射在 domain/install-finalize.js
525
+ // (本轮把"本次装上的**真实来源规格**"一并带进去:取样通道 ⇒ `pnpm add <spec>` 真装;真装失败 ⇒
526
+ // 回落 link: 并如实报「已装上(link 方式)· 真装失败:… · 可一键真装」)。
503
527
  let mode = 'insert'
504
528
  if (await detectBundleOnly(profileDir, installedName)) {
505
529
  mode = 'bundle'
@@ -526,23 +550,10 @@ async function runInstallJob(job, ports, deps = {}) {
526
550
  mode = 'served'
527
551
  job.note = `${installedName} 已由已安装条目提供,仅更新包本身,不重复注册`
528
552
  }
529
- // `deps.persistDeps` 是**离线测试缝**(与 marketProbes/sourceDeps 同一风格):正控/负控必须能
530
- // 确定性地让 lock 那一步失败("故意让 lock 写入失败 ⇒ 必须报未持久化而不是成功"),
531
- // 生产调用方不传第三个参数时形状与默认值一个字都不变。
532
- const persist = await ensurePersisted({ profileDir, patchPath, packageName: installedName, mode, taken, entries: listEntries(ports), registries, deps: deps.persistDeps ?? {} })
533
- const persistNotes = [persist.declared?.depNote, persist.declared?.lockNote, persist.lock?.lockNote, persist.lock?.depNote, ...persist.notes]
534
- .filter((n) => typeof n === 'string' && n !== '')
535
- if (persistNotes.length > 0) job.depNote = job.depNote === undefined ? persistNotes.join(';') : `${job.depNote};${persistNotes.join(';')}`
536
- if (persist.declared?.changed === true) job.declared = persist.declared.spec
537
- // entryId 的语义**与旧行为一致**:只有"用户补丁里的 insert 行"才算本作业注册的行(bundle 层提供的行
538
- // 与运行时已有行都不算,旧代码在这两条路上也不设 entryId)——撤销安装的归属判定依赖这一点。
539
- job.entryId = persist.after.parts.mount.via === 'patch-row' ? (persist.rowId ?? null) : null
540
- job.persisted = persist.persisted
541
- job.persist = { missing: persist.missing, actual: persist.after.actual, note: persist.note }
542
- if (persist.persisted !== true) {
543
- job.persistNote = persist.note
544
- if (persist.suggested !== null && persist.suggested !== undefined && (job.suggestedAction === undefined || job.suggestedAction === null)) job.suggestedAction = persist.suggested
545
- }
553
+ await finalizeInstallPersistence({
554
+ job, profileDir, patchPath, packageName: installedName, mode, taken, entries: listEntries(ports), registries,
555
+ source: installedSource, deps: deps.persistDeps ?? {},
556
+ })
546
557
  job.status = 'done'
547
558
  } catch (error) {
548
559
  job.status = 'failed'
@@ -181,7 +181,7 @@ async function curlManualInstall(profileDir, packageName, registries, signal = n
181
181
  try {
182
182
  writeFileSync(join(target, '.dsh-installed-at'), String(Date.now()), 'utf8')
183
183
  } catch {}
184
- return { version, missingDeps, boxNote: box.note, integrity }
184
+ return { version, missingDeps, boxNote: box.note, integrity, sourceSpec: tarball }
185
185
  } finally {
186
186
  rmSync(tmp, { recursive: true, force: true })
187
187
  }
@@ -360,10 +360,14 @@ async function githubReleaseInstall(profileDir, repo, packageName, options = {})
360
360
  const tgz = join(tmp, 'pkg.tgz')
361
361
  let version = null
362
362
  let sourceNote = null
363
+ // 0.5.41 接线:把**真实下载地址**带回给调用方(收口那一步据此走 `pnpm add <url>` 真装:
364
+ // 依赖与 peer 交给包管理器,而不是只挂一个目录 —— 见 domain/manifest.js#declareProfileDependency)。
365
+ let downloadUrl = null
363
366
  if (plan.ok) {
364
367
  const url = plan.asset?.browser_download_url
365
368
  if (typeof url !== 'string' || url === '') throw new Error(`GitHub 通道:asset ${plan.file} 没有下载地址(接口未返回 browser_download_url)`)
366
369
  await downloadReleaseArtifact(url, tgz, { bin })
370
+ downloadUrl = url
367
371
  version = plan.version ?? null
368
372
  sourceNote = `${plan.repo} 的 release ${plan.release?.tag_name ?? '(无 tag)'} 的资产 ${plan.file}`
369
373
  } else if (plan.sourceFallback !== null) {
@@ -371,7 +375,8 @@ async function githubReleaseInstall(profileDir, repo, packageName, options = {})
371
375
  const tag = plan.sourceFallback.tag
372
376
  // codeload 官方源码 tarball(已验证本机可用 200)。GitHub 黑洞期由上层通道兜底
373
377
  // (pnpm/git 通道),此处失败即报错保留旧版本。
374
- await downloadReleaseArtifact(`https://codeload.github.com/${srcRepo}/tar.gz/refs/tags/${encodeURIComponent(tag)}`, tgz, { bin })
378
+ downloadUrl = `https://codeload.github.com/${srcRepo}/tar.gz/refs/tags/${encodeURIComponent(tag)}`
379
+ await downloadReleaseArtifact(downloadUrl, tgz, { bin })
375
380
  version = String(tag).replace(/^v/iu, '')
376
381
  sourceNote = `${srcRepo} 的 release ${tag} 源码 tarball(该仓库没有任何与包名匹配的资产)`
377
382
  } else {
@@ -409,7 +414,7 @@ async function githubReleaseInstall(profileDir, repo, packageName, options = {})
409
414
  try {
410
415
  writeFileSync(join(target, '.dsh-installed-at'), String(Date.now()), 'utf8')
411
416
  } catch {}
412
- return { version, missingDeps, boxNote: box.note, source: 'github', sourceNote }
417
+ return { version, missingDeps, boxNote: box.note, source: 'github', sourceNote, sourceSpec: downloadUrl }
413
418
  } finally {
414
419
  rmSync(tmp, { recursive: true, force: true })
415
420
  }