@noob-stupid/dsh-plugin-console 0.5.85 → 0.5.87

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.
@@ -16,6 +16,8 @@ import { downloadReleaseArtifact, releaseInstallTarget, selectReleaseInstall } f
16
16
  import { verifyTarballDigest } from './install-verify.js'
17
17
  import { presetJobView } from './preset-install.js'
18
18
  import { readBundlePatchRefNames } from './bundle-refs.js'
19
+ // 盒子验证(装前静态校验)2026-10-18 搬进 install-box.js(本文件贴着 600 行硬顶);名字与语义一字未变。
20
+ import { parseBundlePatchRefs, verifyPackageBox } from './install-box.js'
19
21
 
20
22
  /** bundle 包判定:声明 dsh.bundle 的包一律按官方 `dsh plugin add` 行为追加为
21
23
  * profile bundle 层(其 cordis.patch.yml 的插入行在下次启动时组合进树)。
@@ -198,11 +200,19 @@ async function curlManualInstall(profileDir, packageName, registries, signal = n
198
200
  * 修法:补上第三个出口 —— 两条通道都 settle(无论成败)即刻收工;同时把定时器清掉,
199
201
  * 否则每次竞速都会留下一个 120 秒的挂起定时器,拖住进程退出。
200
202
  * 第 4 个参数是可选注入(单测用:把两条通道换成桩,才能离线断言"都失败 → 立刻收工"的时延语义;
201
- * capMs 也只是给单测缩短兜底时长,生产一律用默认 120 秒)。 */
202
- async function raceInstallChannels(profileDir, name, registries, impls = {}) {
203
+ * capMs 也只是给单测缩短兜底时长,生产一律用默认 120 秒)。
204
+ *
205
+ * ★ 0.5.87 改错(真机 2026-10-18):第 1 个规格参数**同时喂给两条腿**是错的 —— pnpm 要的是
206
+ * `name@version`,而 curl 要的是**裸包名 + 目标版本**(`pkg@1.2.3` 对 curl 是非法包名,必然 404)。
207
+ * 第 5 个参数是可选扩展 `{ packageName, exactVersion }`:给了就按它跑 curl 腿(没给则与旧行为逐字相同:
208
+ * 用 `name` 当包名、不传版本 ⇒ 取 dist-tags.latest)。旧调用点与旧单测一个都不用改。 */
209
+ async function raceInstallChannels(profileDir, name, registries, impls = {}, opts = {}) {
203
210
  const runPnpm = typeof impls.pnpmInstall === 'function' ? impls.pnpmInstall : pnpmInstall
204
211
  const runCurl = typeof impls.curlManualInstall === 'function' ? impls.curlManualInstall : curlManualInstall
205
212
  const capMs = Number.isFinite(impls.capMs) && impls.capMs > 0 ? impls.capMs : 120000
213
+ // curl 腿的包名/版本(见上方 ★):只认非空字符串,其余一律回落旧形态
214
+ const curlName = typeof opts.packageName === 'string' && opts.packageName !== '' ? opts.packageName : name
215
+ const curlVersion = typeof opts.exactVersion === 'string' && opts.exactVersion !== '' ? opts.exactVersion : null
206
216
  const controller = new AbortController()
207
217
  const signal = controller.signal
208
218
  const pnpmTask = (async () => {
@@ -219,7 +229,7 @@ async function raceInstallChannels(profileDir, name, registries, impls = {}) {
219
229
  throw lastError ?? new Error('pnpm 通道失败')
220
230
  })()
221
231
  const curlTask = (async () => {
222
- const info = await runCurl(profileDir, name, registries, signal)
232
+ const info = await runCurl(profileDir, curlName, registries, signal, curlVersion)
223
233
  return { channel: 'curl', info }
224
234
  })()
225
235
  const waitSuccess = (promise) => promise.then((value) => ({ value }), () => new Promise(() => {}))
@@ -236,73 +246,6 @@ async function raceInstallChannels(profileDir, name, registries, impls = {}) {
236
246
  }
237
247
  }
238
248
 
239
- /**
240
- * 盒子实验验证:解压后的包目录先通过静态校验再允许覆盖正式位置。
241
- * - package.json 必须可解析且 name 与安装目标一致
242
- * - main / exports 入口文件必须真实存在(防"装上了但加载即崩")
243
- * - bundle patch(cordis.patch.yml)引用的包必须已就位(防聚合包引用缺失崩溃)
244
- * 失败抛错 → 调用方保留旧版本(安装不中断服务)。
245
- */
246
- function verifyPackageBox(pkgPath, profileDir, packageName, refCheckRoot = null) {
247
- const note = []
248
- const pkgRaw = readFileSync(join(pkgPath, 'package.json'), 'utf8')
249
- const pkg = JSON.parse(pkgRaw)
250
- if (pkg.name !== packageName) {
251
- throw new Error(`盒子验证失败:包名不符(tarball 内为 ${pkg.name},期望 ${packageName}),已保留旧版本`)
252
- }
253
- // 入口存在性:main 字段 / exports.'.'(字符串或对象 default)指向的文件必须存在
254
- let entry = typeof pkg.main === 'string' ? pkg.main : null
255
- if (entry === null && pkg.exports && typeof pkg.exports === 'object') {
256
- const dot = pkg.exports['.'] ?? pkg.exports['./package.json'] === undefined ? pkg.exports['.'] : null
257
- if (typeof dot === 'string') entry = dot
258
- else if (dot && typeof dot === 'object') entry = typeof dot.default === 'string' ? dot.default : null
259
- }
260
- if (entry !== null) {
261
- const entryPath = join(pkgPath, ...entry.split('/'))
262
- if (!existsSync(entryPath)) {
263
- throw new Error(`盒子验证失败:入口文件缺失(${entry}),已保留旧版本`)
264
- }
265
- }
266
- // bundle patch 引用预检:引用的包必须已存在于目标 node_modules(防聚合包半更新崩溃)。
267
- // refCheckRoot 指定检查根(宿主插件在根层 node_modules,与 profile 层不同)。
268
- const checkRoot = refCheckRoot ?? join(profileDir, 'node_modules')
269
- try {
270
- const patchRel = pkg.dsh?.bundle?.patch
271
- if (typeof patchRel === 'string') {
272
- const bundlePatch = join(pkgPath, patchRel)
273
- if (existsSync(bundlePatch)) {
274
- const refs = parseBundlePatchRefs(readFileSync(bundlePatch, 'utf8'))
275
- const missingRefs = refs.filter((r) => !existsSync(join(checkRoot, r.name)))
276
- if (missingRefs.length > 0) {
277
- note.push(`bundle 引用缺失:${missingRefs.map((r) => r.name).join('、')}(安装后由聚合完整性检查补齐/禁用)`)
278
- }
279
- if (refs.length > 0) note.push(`bundle 引用 ${refs.length} 个已就位`)
280
- }
281
- }
282
- } catch (error) {
283
- if (error instanceof Error && error.message.startsWith('盒子验证失败')) throw error
284
- }
285
- return { note: note.length > 0 ? note.join(';') : null }
286
- }
287
-
288
- /** 解析 bundle patch(cordis.patch.yml)的 insert 引用列表(id + 包名)。 */
289
- function parseBundlePatchRefs(text) {
290
- const refs = []
291
- const lines = text.split(/\r?\n/u)
292
- let inInsert = false
293
- for (let i = 0; i < lines.length; i += 1) {
294
- const line = lines[i]
295
- if (/^- insert:\s*$/u.test(line)) { inInsert = true; continue }
296
- if (/^- /u.test(line) && !/^ {4}- /u.test(line)) inInsert = false
297
- if (!inInsert) continue
298
- const idM = line.match(/^ {4}- id: ([A-Za-z0-9_.-]+)\s*$/u)
299
- if (!idM) continue
300
- const nameM = (lines[i + 1] ?? '').match(/^ {6}name: ['"]([^'"]+)['"]\s*$/u)
301
- if (nameM) refs.push({ id: idM[1], name: nameM[1] })
302
- }
303
- return refs
304
- }
305
-
306
249
  /**
307
250
  * 捆绑依赖补装(事故教训:dsh-web-ui-all 更新后 17 个捆绑依赖缺失 → 服务加载崩溃):
308
251
  * curl/GitHub 通道只解压主包,这里逐个补装直接依赖(pnpm → curl 依次尝试)。
@@ -514,6 +457,10 @@ function installJobView(job) {
514
457
  bundle: job.bundle ?? false,
515
458
  ai: job.ai ?? false,
516
459
  aiNote: job.aiNote ?? null, version: job.version ?? null, // ★ 0.5.82:目标版本一并投影
460
+ // ★ 0.5.87(真机 2026-10-18):上面那个是**请求点名的目标版本**,不等于装上了什么 ——
461
+ // 本次真正落到磁盘的版本 + 版本闸的结论(`versionMiss` 非空 ⇒ 目标版本没装上,作业必为 failed)。
462
+ // 面板拿到这两项才分得清"更新到 0.2.1-rc.2"与"报了个目标版本号、盘上还是旧版"。
463
+ installedVersion: job.installedVersion ?? null, versionMiss: job.versionMiss ?? null,
517
464
  subpackages: job.subpackages ?? null,
518
465
  source: job.source ?? 'github',
519
466
  curlNote: job.curlNote ?? null,
@@ -19,7 +19,7 @@
19
19
  // · 任何单个包出问题都**不抛**,如实降级成该行的一个字段(一份清单不能因为一行坏掉就整体消失)。
20
20
  //
21
21
  // 分层:L1 domain —— 不认识 cordis ctx;home / profileDir / patchPath 全部由调用方解开后传入。
22
- import { readFileSync } from 'node:fs'
22
+ import { readFileSync, readdirSync, statSync } from 'node:fs'
23
23
  import { join } from 'node:path'
24
24
  import { canonicalSourceSpec, sourceSpecTarget } from './dep-source.js'
25
25
  import { planLinkSelfSufficiency, listSelfSufficiencyRecords } from './link-self-sufficiency.js'
@@ -29,6 +29,34 @@ const readJsonSafe = (file) => {
29
29
  try { return JSON.parse(readFileSync(file, 'utf8')) } catch { return null }
30
30
  }
31
31
 
32
+ /** 该目录里不参与"改动时间"的条目(`node_modules` 上万文件、`.git` 每次操作都在动,两者都不代表"我在改这个插件")。 */
33
+ const MTIME_IGNORED_ENTRIES = new Set(['node_modules', '.git'])
34
+
35
+ /**
36
+ * 目标目录的**改动时间**(ISO;拿不到就是 null,绝不编造)。
37
+ *
38
+ * 为什么不是只取目录本身:**Windows 上改文件内容不会更新目录的 mtime**(只有增删条目才会),
39
+ * 只取目录会让人看到"我明明刚改过、时间还是上周" —— 而这个字段的用途恰恰是「这是不是我正在改的那个」。
40
+ * 为什么不做递归:`node_modules` 动辄上万文件,一次递归会把 `/links` 拖慢一个数量级;
41
+ * 浅扫(目录 + 它**直接下一层**,跳过 node_modules/.git)已经覆盖 package.json / 入口文件 /
42
+ * `src` 这类常见改动点的 mtime,成本是一次 readdir + N 次 stat。
43
+ * 拿不到(目录被删/无权限)一律 null,由面板渲染成「—」。
44
+ */
45
+ function targetMtimeOf(dir) {
46
+ let newest = 0
47
+ try { newest = statSync(dir).mtimeMs } catch { return null }
48
+ let entries = []
49
+ try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return new Date(newest).toISOString() }
50
+ for (const entry of entries) {
51
+ if (MTIME_IGNORED_ENTRIES.has(entry.name)) continue
52
+ try {
53
+ const when = statSync(join(dir, entry.name)).mtimeMs
54
+ if (when > newest) newest = when
55
+ } catch {}
56
+ }
57
+ return new Date(newest).toISOString()
58
+ }
59
+
32
60
  /** 路径型来源(`link:` / `file:`)—— 只有这一族是"开发式安装",才有目标目录可体检。 */
33
61
  const PATH_SPEC_RE = /^(?:link:|file:)/u
34
62
 
@@ -78,6 +106,10 @@ function buildLinkInventory({ profileDir, patchPath = null, entries = null, home
78
106
  spec,
79
107
  target: null,
80
108
  targetExists: false,
109
+ // ★ 0.5.87 加法:目标目录的最后修改时间(ISO 字符串;拿不到就是 null,由面板渲染成「—」)。
110
+ // 为什么需要:开发者常常同时有 plugin-src 下的好几个副本,「这是不是我正在改的那个」
111
+ // 只能靠时间判断。**绝不编造**:stat 失败一律 null,不写 0、不写当前时间。
112
+ targetMtime: null,
81
113
  version: null,
82
114
  ready: null,
83
115
  missingDeps: [],
@@ -104,8 +136,13 @@ function buildLinkInventory({ profileDir, patchPath = null, entries = null, home
104
136
  item.foreignPeers = plan.foreignPeers ?? []
105
137
  item.note = plan.note ?? plan.reason ?? null
106
138
  if (item.target !== null && item.targetExists) {
107
- const pkg = readJsonSafe(join(item.target, 'package.json'))
108
- item.version = typeof pkg?.version === 'string' ? pkg.version : null
139
+ // 版本与修改时间各自独立 try:读 package.json 坏掉不该连"文件是什么时候动的"也一并丢掉,
140
+ // 反之 stat 失败也不该吞掉版本号。
141
+ try {
142
+ const pkg = readJsonSafe(join(item.target, 'package.json'))
143
+ item.version = typeof pkg?.version === 'string' ? pkg.version : null
144
+ } catch {}
145
+ try { item.targetMtime = targetMtimeOf(item.target) } catch { item.targetMtime = null }
109
146
  }
110
147
  } catch (error) {
111
148
  item.error = error instanceof Error ? error.message : String(error)
@@ -139,4 +176,4 @@ function isLinkTargetOfProfile(profileDir, candidate) {
139
176
  return false
140
177
  }
141
178
 
142
- export { PATH_SPEC_RE, pathDepsOf, buildLinkInventory, isLinkTargetOfProfile }
179
+ export { PATH_SPEC_RE, MTIME_IGNORED_ENTRIES, targetMtimeOf, pathDepsOf, buildLinkInventory, isLinkTargetOfProfile }
@@ -327,6 +327,72 @@ async function ensureLinkSelfSufficiency({ profileDir, packageName, spec = null,
327
327
  }
328
328
  }
329
329
 
330
+ /**
331
+ * **定点**补齐:只做用户点的那一件事(0.5.87 加法,给面板的 [补齐依赖] / [造垫片] 两个按钮)。
332
+ *
333
+ * 与 `ensureLinkSelfSufficiency` 的关系:那个是**安装主路径**上的"缺什么补什么"(全做,别改它);
334
+ * 本函数是"点哪个按钮做哪件事"的定点版。两条出路本来就不是一回事 ——
335
+ * · `deps` = 在链接目标里跑 pnpm install(补 dependencies);
336
+ * · `shims` = 只建框架 peer 垫片(绝不跑 pnpm、绝不联网);
337
+ * · `all` = 两件都做(等价于主路径的顺序:先依赖、后垫片,因为 pnpm install 会修剪 node_modules)。
338
+ * 为什么必须分开:共用一份实现会让 [造垫片] 顺手跑一次 pnpm install —— 那是**用户没要求的写盘**;
339
+ * 反之 [补齐依赖] 顺手建垫片则会让"只补依赖"变成两种副作用。
340
+ *
341
+ * 判据与写盘全部复用同族函数(planLinkSelfSufficiency / installTargetDependencies /
342
+ * createPeerShims / writeSelfSufficiencyRecord / composeSelfSufficiencyNote),**不新写**任何判据。
343
+ * 任何情况都**不抛**;`ok` = 这次尝试本身没出意外,`ready` 才是"现在自足了吗"。
344
+ */
345
+ async function repairLinkTarget({ profileDir, packageName, kind = 'all', spec = null, home = null, deps = {} } = {}) {
346
+ const homeDir = typeof home === 'string' && home !== '' ? home : dshHome()
347
+ const wanted = kind === 'deps' ? 'deps' : kind === 'shims' ? 'shims' : 'all'
348
+ const empty = { ok: true, kind: wanted, applies: false, ready: true, changed: false, installed: null, created: [], skipped: [], note: null, plan: null }
349
+ try {
350
+ const plan = planLinkSelfSufficiency({ profileDir, packageName, spec, home: homeDir })
351
+ if (plan.applies !== true) return { ...empty, note: plan.reason, plan }
352
+ if (plan.targetExists !== true) return { ...empty, applies: true, ready: false, note: plan.note, plan }
353
+ // 幂等 + 不谎报:该做的那件事**本来就没有对象**时,如实说"没有可补的",一个字节都不写。
354
+ if (wanted === 'deps' && plan.missingDeps.length === 0) {
355
+ return { ...empty, applies: true, ready: plan.ready === true, note: `${packageName}:没有缺的 dependencies(依赖已齐,未改动任何东西)`, plan }
356
+ }
357
+ if (wanted === 'shims' && plan.shimmablePeers.length === 0) {
358
+ const why = plan.missingPeers.length === 0
359
+ ? '没有缺的框架 peer(未改动任何东西)'
360
+ : `缺的 peer 都不在框架根里、造不出垫片(${plan.unavailablePeers.join(', ')})—— 未改动任何东西`
361
+ return { ...empty, applies: true, ready: plan.ready === true, note: `${packageName}:${why}`, plan }
362
+ }
363
+ if (wanted === 'all' && plan.ready === true) {
364
+ return { ...empty, applies: true, ready: true, note: `${packageName}:链接目标已自足(未改动任何东西)`, plan }
365
+ }
366
+ let installed = null
367
+ if (wanted !== 'shims' && plan.missingDeps.length > 0) installed = await installTargetDependencies({ target: plan.target, deps })
368
+ let created = []
369
+ let skipped = []
370
+ if (wanted !== 'deps') {
371
+ // 顺序与安装主路径一致:先依赖、后垫片(pnpm install 会把先建好的垫片修剪掉)
372
+ const afterDeps = planLinkSelfSufficiency({ profileDir, packageName, spec: plan.spec, home: homeDir })
373
+ const shimmed = createPeerShims({ target: plan.target, peers: afterDeps.shimmablePeers, home: homeDir, deps })
374
+ created = shimmed.created
375
+ skipped = shimmed.skipped
376
+ if (created.length > 0) writeSelfSufficiencyRecord(homeDir, packageName, created)
377
+ }
378
+ const finalPlan = planLinkSelfSufficiency({ profileDir, packageName, spec: plan.spec, home: homeDir })
379
+ return {
380
+ ok: true,
381
+ kind: wanted,
382
+ applies: true,
383
+ ready: finalPlan.ready === true,
384
+ changed: created.length > 0 || installed?.ok === true,
385
+ installed,
386
+ created,
387
+ skipped,
388
+ note: composeSelfSufficiencyNote({ packageName, installed, created, skipped, finalPlan }),
389
+ plan: finalPlan,
390
+ }
391
+ } catch (error) {
392
+ return { ...empty, ok: false, applies: true, ready: false, note: `${packageName}:补齐时出错(${String(error?.message ?? error).slice(0, 300)})`, plan: null }
393
+ }
394
+ }
395
+
330
396
  /** 列出全部自足性记录(报告/体检用;读不到就是空数组)。 */
331
397
  function listSelfSufficiencyRecords({ home = null } = {}) {
332
398
  const homeDir = typeof home === 'string' && home !== '' ? home : dshHome()
@@ -408,6 +474,7 @@ export {
408
474
  selfSufficiencyRecordPath,
409
475
  planLinkSelfSufficiency,
410
476
  ensureLinkSelfSufficiency,
477
+ repairLinkTarget,
411
478
  removeLinkSelfSufficiency,
412
479
  listSelfSufficiencyRecords,
413
480
  installTargetDependencies,
@@ -120,7 +120,7 @@ async function specOf(profileDir, packageName) {
120
120
  * 由 `link-self-sufficiency.js` 补齐依赖与框架 peer 垫片根。
121
121
  * 优先级刻意保持"registry 可解析 → 版本号"在最前:既有 registry 流程一字不改(不倒退)。
122
122
  */
123
- async function planDependencySpec({ profileDir, packageName, version, registries = [], probe = null, home = undefined, profile = null, sourceSpec = null }) {
123
+ async function planDependencySpec({ profileDir, packageName, version, registries = [], probe = null, home = undefined, profile = null, sourceSpec = null, registryProven = false }) {
124
124
  const doProbe = typeof probe === 'function' ? probe : probeRegistryPackage
125
125
  const realSpec = typeof sourceSpec === 'string' && sourceSpec.trim() !== '' ? sourceSpec.trim() : null
126
126
  const linkable = () => materializePackageForLink(profileDir, packageName, home === undefined ? {} : { home })
@@ -155,6 +155,20 @@ async function planDependencySpec({ profileDir, packageName, version, registries
155
155
  result = { resolvable: false, hasVersion: false, tried: [error instanceof Error ? error.message : String(error)] }
156
156
  }
157
157
  if (result?.resolvable === true && result.hasVersion !== false) return { form: 'version', spec: version, note: null, suggestion: null }
158
+ // ★ 0.5.87 加法(用户红线:「正常下载安装/更新绝不能走 link:」):这份物是**我们自己从 registry
159
+ // 下载成功**的(curl 通道拿到的 dist.tarball,摘要已校验)⇒ "registry 查无此包/此版本"的探测结论
160
+ // 只能是**探测这次没探成**(镜像元数据滞后/CDN 边缘不一致),不是事实。此时按版本号写回,
161
+ // 绝不物化成 link:(那会把一个正常下载安装的包变成"开发式安装",下次 pnpm 操作还会被改形态)。
162
+ // 没传 registryProven 时(默认 false)行为与改动前**逐字相同**。
163
+ if (registryProven === true) {
164
+ return {
165
+ form: 'version',
166
+ spec: version,
167
+ note: `${packageName}:本次是**从 registry 实际下载成功**的(tarball 已校验摘要),故按版本号 ${version} 记录;`
168
+ + 'registry 元数据探测这次没能确认它(多为镜像/元数据滞后)——下载成功即证明该版本在,**不**据此写成 link:/本地副本',
169
+ suggestion: null,
170
+ }
171
+ }
158
172
  // 来源型:调用方给了真实来源规格 ⇒ 默认真装(不写 link:)。拿不到真实规格才退回 link:。
159
173
  if (probeFailureKind(result) === 'fetch-404' && realSpec !== null) return asSource()
160
174
  if (probeFailureKind(result) === 'fetch-404') {
@@ -184,7 +198,7 @@ async function planDependencySpec({ profileDir, packageName, version, registries
184
198
  * lockSynced: boolean, lockNote: string|null, depNote: string|null, suggested: object|null }}
185
199
  */
186
200
  async function declareProfileDependency(profileDir, packageName, version = null, options = {}) {
187
- const { syncLock = true, registries = [], probe = null, home = undefined, profile = null, sourceSpec = null, installSource = null, sourceOrigin = null, sourceKind = null } = options
201
+ const { syncLock = true, registries = [], probe = null, home = undefined, profile = null, sourceSpec = null, installSource = null, sourceOrigin = null, sourceKind = null, registryProven = false } = options
188
202
  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
203
  const realSpec = typeof sourceSpec === 'string' && sourceSpec.trim() !== '' ? sourceSpec.trim() : null
190
204
  const homeOpt = home === undefined ? {} : { home }
@@ -217,7 +231,7 @@ async function declareProfileDependency(profileDir, packageName, version = null,
217
231
  note: `${packageName}:**源码安装(本地物化)** —— 该包在 npm registry 上找不到,只能拿到源码;已按路径型来源记录(${realSpec})。不经 registry 解析、不经 tarball 完整性校验,缺失依赖与框架 peer 由 link 自足性补齐`,
218
232
  suggestion: null,
219
233
  }
220
- : await planDependencySpec({ profileDir, packageName, version: resolved, registries, probe, home, profile, sourceSpec: installSpec })
234
+ : await planDependencySpec({ profileDir, packageName, version: resolved, registries, probe, home, profile, sourceSpec: installSpec, registryProven })
221
235
  // ★ 本轮接线(2026-10-06):盘上那份是**取样通道**铺的(curl / GitHub release 资产 / 源码装配),
222
236
  // 而不是包管理器装的 —— 而 registry 又查无此包(plan 判成来源型)⇒ **补一次真装**,
223
237
  // 把依赖交给 pnpm(与官方 dsh plugin add 同一条通道)。
@@ -454,6 +454,11 @@ async function ensurePersisted({ profileDir, patchPath, packageName, mode = 'ins
454
454
  ...(typeof sourceSpec === 'string' && sourceSpec !== '' ? { sourceSpec, sourceOrigin, sourceKind } : {}),
455
455
  ...(typeof deps.installSource === 'function' ? { installSource: deps.installSource } : {}),
456
456
  ...(deps.syncLock === false ? { syncLock: false } : {}),
457
+ // ★ 0.5.87 加法(用户红线:正常下载安装/更新绝不能产出 link:/file:):`registry-tarball` 是
458
+ // curl 通道从 registry 元数据里拿到 dist.tarball 并**校验摘要后**铺进去的 —— 这份物**证明**
459
+ // 该版本在 registry 上存在。于是探测说"查无此包/此版本"只能算探测失败,不能据此物化成 link:
460
+ // (详见 manifest.js#planDependencySpec 的 registryProven 分支)。其它来源一律不置位。
461
+ ...(sourceKind === 'registry-tarball' ? { registryProven: true } : {}),
457
462
  })
458
463
  if (declared.changed !== true) notes.push(`清单里已有这个包(实际是 ${declared.spec ?? disk.version})`)
459
464
  if (declared.depNote !== null && declared.depNote !== undefined) notes.push(declared.depNote)
@@ -511,7 +511,12 @@ async function reconcileLockfile({ profileDir, packageName = null, packages = nu
511
511
  suggestedActions.push(suggestedPinAction({ packageName: item.name, version: item.installed, profileDir, reason: why }))
512
512
  continue
513
513
  }
514
- if (actual === item.spec) {
514
+ // ★ 0.5.87 改错(真机 E2E 2026-10-18 复现):这条路的计划是 **`name@版本`**(给 pnpm add 用的
515
+ // 完整规格),而清单 `dependencies` 里存的是**版本号那一段**。旧代码拿整串去比 ⇒ 明明已经写对
516
+ // (清单 = lock = 最新版)也一律报「写回**未生效**,请执行「钉住」动作」,还顺手挂上一个 pin 动作。
517
+ // 表现:更新成功、`persisted: true`,面板却同时说"没写进去"。这里只比**要写进清单的那一段**。
518
+ const wantedInManifest = item.spec.startsWith(`${item.name}@`) ? item.spec.slice(item.name.length + 1) : item.spec
519
+ if (actual === wantedInManifest) {
515
520
  if (item.spec.startsWith('link:') && item.reason !== null) {
516
521
  depNotes.push(`${item.name}:${item.reason},已按源码安装(本地物化)记录依赖(${actual})—— 不经 npm registry 解析、不经 tarball 完整性校验,pnpm 重建 lock 也能装上`)
517
522
  }
@@ -0,0 +1,149 @@
1
+ // L1 · domain —— 取消钉住:把 profile 清单里那条 `link:` / `file:` 依赖摘掉(0.5.87 加法)
2
+ //
3
+ // 为什么需要(用户 2026-10-18 的原话):link 面板过去**只能看不能改** —— 看到"这一条钉着本地目录"
4
+ // 却没有任何办法退回去。钉住有入口(/pin-dev-dir),退出没有。
5
+ //
6
+ // 语义词边界(与 `routes/revoke-force.js` 同一套"窄动作"纪律,**照抄它的安全语义**):
7
+ // · 只对**清单里真写着 `link:`/`file:` 的那一条**生效 —— 不是这种来源就一律拒(不猜、不代劳);
8
+ // · 两段式:`planUnpinDevDir` 纯只读先给"将要发生什么",`applyUnpinDevDir` 才会动手;
9
+ // · 动手时**先备份清单**,pnpm 报成功但**读回**发现清单里还有它 ⇒ 回滚并如实报错(绝不谎报);
10
+ // · **只摘依赖**:不删开发目录、不删我们造过的 peer 垫片、不碰 dsh.profile.bundles 之外的东西。
11
+ // 它若还在 `dsh.profile.bundles` 里,撤销后框架会跳过那个 bundle —— 这是**必说的后果**,
12
+ // 由 plan.warnings 与 apply.note 明说,而不是替用户顺手改 bundles(那属于「卸载」的语义)。
13
+ //
14
+ // 为什么**不自己写清单**:`pnpm remove` 一个动作同时负责清单 / pnpm-lock.yaml / node_modules 三处;
15
+ // 我们若先删清单再调 pnpm,失败时就是"清单没了、锁没动、链接还在"的半截状态。
16
+ // 所以:备份 → 交给 pnpm → **读回三处**如实汇报。pnpm 是墙钟动作,执行器可注入(离线测试用)。
17
+ //
18
+ // 分层:L1 domain —— 收已解开的 profileDir;不认 ctx。
19
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
20
+ import { join } from 'node:path'
21
+ import { NPM_NAME_RE, canonicalSourceSpec, sourceSpecTarget } from './dep-source.js'
22
+ import { pnpmRemove } from './install-cleanup.js'
23
+ import { readBundlesSync } from './persist.js'
24
+ import { lockVersion } from './selfupdate.js'
25
+
26
+ /** 路径型来源(`link:` / `file:`)—— 只有这一族谈得上"钉住 / 取消钉住"。 */
27
+ const PATH_SPEC_RE = /^(?:link:|file:)/u
28
+
29
+ const readJsonSafe = (file) => {
30
+ try { return JSON.parse(readFileSync(file, 'utf8')) } catch { return null }
31
+ }
32
+
33
+ /** `node_modules/<包名>` 的路径(不判断存在性)。 */
34
+ function nodeModulesPathOf(profileDir, packageName) {
35
+ return join(profileDir, 'node_modules', ...String(packageName).split('/'))
36
+ }
37
+
38
+ /** 清单里该依赖当前的 spec(没有就 null)。 */
39
+ function manifestDepSpec(profileDir, packageName) {
40
+ const deps = readJsonSafe(join(profileDir, 'package.json'))?.dependencies
41
+ if (deps === null || typeof deps !== 'object') return null
42
+ const spec = deps[packageName]
43
+ return typeof spec === 'string' && spec !== '' ? spec : null
44
+ }
45
+
46
+ /**
47
+ * 规划一次取消钉住(**纯只读**,不写盘、不 spawn)。
48
+ *
49
+ * @param {object} p
50
+ * @param {string} p.profileDir profile 根目录
51
+ * @param {string} p.packageName 要取消钉住的包名
52
+ * @returns {{ ok: boolean, status: number, error: string|null, packageName: string, spec: string|null,
53
+ * target: string|null, targetExists: boolean, inBundles: boolean, bundles: string[],
54
+ * warnings: string[], steps: string[] }}
55
+ */
56
+ function planUnpinDevDir({ profileDir, packageName } = {}) {
57
+ const base = {
58
+ ok: false, status: 400, error: null, packageName: typeof packageName === 'string' ? packageName : '',
59
+ spec: null, target: null, targetExists: false, inBundles: false, bundles: [], warnings: [], steps: [],
60
+ }
61
+ if (typeof profileDir !== 'string' || profileDir === '') return { ...base, error: '拿不到 profile 目录,拒绝改动' }
62
+ if (typeof packageName !== 'string' || !NPM_NAME_RE.test(packageName)) return { ...base, error: '包名无效' }
63
+ const spec = manifestDepSpec(profileDir, packageName)
64
+ if (spec === null) return { ...base, error: `${packageName} 不在 profile 清单的 dependencies 里 —— 没有可取消的钉住` }
65
+ const canonical = canonicalSourceSpec(spec)
66
+ if (typeof canonical !== 'string' || !PATH_SPEC_RE.test(canonical)) {
67
+ return { ...base, spec, error: `${packageName} 的来源是 ${spec},不是 link:/file: —— 它本来就没被钉到本地目录,无需取消钉住` }
68
+ }
69
+ const target = sourceSpecTarget(profileDir, canonical)
70
+ const targetExists = target !== null && existsSync(target)
71
+ let bundles = []
72
+ try { bundles = readBundlesSync(profileDir) } catch { bundles = [] }
73
+ const inBundles = bundles.includes(packageName)
74
+ const warnings = inBundles
75
+ ? [`它仍在 dsh.profile.bundles 里:取消钉住后这个 bundle 会解析不到(重启后被框架跳过、插件不可用)。要连 bundle 一起去掉请用「卸载」;只想换回发行版请重新安装它。`]
76
+ : []
77
+ if (target !== null && !targetExists) {
78
+ warnings.push(`目标目录已经不在了(${target})—— 这一条是"指向不存在目录的陈旧链接",取消它只是清理,不会有任何功能损失。`)
79
+ }
80
+ const steps = [
81
+ `备份 profile 清单(package.json.bak-unpin-<时间戳>)`,
82
+ `执行 pnpm remove ${packageName}(同时清掉 pnpm-lock.yaml 条目与 node_modules 里的链接)`,
83
+ target === null ? '读回核对:清单 / lock / node_modules 三处都不再有它' : `读回核对:清单 / lock / node_modules 三处都不再有它(目标目录 ${target} 不会被删)`,
84
+ ]
85
+ return { ...base, ok: true, status: 200, spec, target, targetExists, inBundles, bundles, warnings, steps }
86
+ }
87
+
88
+ /** 把清单写回改动前(回滚用;失败只返回 false,绝不抛)。 */
89
+ function restoreManifest(manifestPath, text) {
90
+ try { writeFileSync(manifestPath, text, 'utf8'); return true } catch { return false }
91
+ }
92
+
93
+ /**
94
+ * 执行取消钉住。**失败即回滚清单**,并如实汇报三处(清单 / lock / node_modules)的真实状态。
95
+ *
96
+ * @param {object} p
97
+ * @param {string} p.profileDir profile 根目录
98
+ * @param {string} p.packageName 要取消钉住的包名
99
+ * @param {(args: { profileDir: string, packageName: string }) => Promise<void>} [p.runRemove]
100
+ * 注入的移除执行器(离线测试用);缺省走真实 pnpm remove。
101
+ */
102
+ async function applyUnpinDevDir({ profileDir, packageName, runRemove = null } = {}) {
103
+ const plan = planUnpinDevDir({ profileDir, packageName })
104
+ if (plan.ok !== true) return { ...plan, changed: false, backup: null, removed: null }
105
+ const manifestPath = join(profileDir, 'package.json')
106
+ const before = readFileSync(manifestPath, 'utf8')
107
+ const backup = `${manifestPath}.bak-unpin-${Date.now()}`
108
+ try { writeFileSync(backup, before, 'utf8') } catch {}
109
+ const failed = (reason, restored) => ({
110
+ ...plan, ok: false, status: 500, changed: false, backup, removed: null,
111
+ error: `${reason}\n(清单${restored ? '已回滚到改动前' : '**回滚失败**,请手动检查'},backup: ${backup})`,
112
+ })
113
+ // ① 备份 → ② 交给 pnpm(清单 / lock / node_modules 由同一个动作负责)
114
+ try {
115
+ if (typeof runRemove === 'function') await runRemove({ profileDir, packageName })
116
+ else await pnpmRemove(profileDir, packageName)
117
+ } catch (error) {
118
+ const restored = restoreManifest(manifestPath, before)
119
+ return failed(`pnpm remove 失败:${error instanceof Error ? error.message : String(error)}`, restored)
120
+ }
121
+ // ③ 读回三处(不读回就报成功 = 谎报)
122
+ const afterSpec = manifestDepSpec(profileDir, plan.packageName)
123
+ const manifestCleared = afterSpec === null
124
+ const nodeModulesCleared = !existsSync(nodeModulesPathOf(profileDir, plan.packageName))
125
+ let lockCleared = false
126
+ try { lockCleared = lockVersion(profileDir, plan.packageName) === null } catch { lockCleared = false }
127
+ if (!manifestCleared) {
128
+ const restored = restoreManifest(manifestPath, before)
129
+ return failed(`pnpm 报告成功,但读回发现清单里仍有 ${plan.packageName}(实际 ${JSON.stringify(afterSpec)})`, restored)
130
+ }
131
+ const note = [
132
+ `已从 profile 清单移除 ${plan.packageName}(原来源 ${plan.spec})`,
133
+ `pnpm-lock.yaml 条目${lockCleared ? '已清除' : '仍在(lock 没跟上这一步,下一次 pnpm 操作会重算)'}`,
134
+ `node_modules 里的链接${nodeModulesCleared ? '已清除' : '仍在(pnpm 没清掉它,重启后它可能仍被解析到)'}`,
135
+ '开发目录与代码未被删除',
136
+ ...plan.warnings,
137
+ ].join(';')
138
+ return {
139
+ ...plan,
140
+ ok: true,
141
+ status: 200,
142
+ changed: true,
143
+ backup,
144
+ removed: { manifest: manifestCleared, lock: lockCleared, nodeModules: nodeModulesCleared },
145
+ note,
146
+ }
147
+ }
148
+
149
+ export { PATH_SPEC_RE, manifestDepSpec, nodeModulesPathOf, planUnpinDevDir, applyUnpinDevDir }
@@ -34,6 +34,8 @@ import { routeAdaptUnlock, routeAdaptUnlockAll, routeCleanResiduals, routeSelfUp
34
34
  import { routeSkillRemove, routeSkillToggle, routeSkillsInstalledGet } from './skills.js'
35
35
  import { routeGiteeOauthCallbackGet, routeGiteeOauthUrlGet, routeRegistryScan, routeSources, routeSourcesGet } from './sources.js'
36
36
  import { routeCompatExempt, routeDetails, routeDevRoots, routeLinksGet, routePinDevDir, routeStateGet } from './state.js'
37
+ // 0.5.87 加法:link 面板的写动作(补依赖/垫片 + 取消钉住)—— 判据在 domain 层,这里只做白名单与回包
38
+ import { routeLinkRepair, routeUnpinDevDir } from './link-actions.js'
37
39
 
38
40
  const ROUTE_PREFIX = '/plugin-console'
39
41
 
@@ -43,9 +45,10 @@ const ROUTES_EARLY = [
43
45
  { methods: ['GET'], path: `${ROUTE_PREFIX}/links`, handler: routeLinksGet },
44
46
  // 兼容豁免(0.5.56 加法):把"被跳过的 bundle"的三条出路之一做成真正可点的动作
45
47
  { methods: ['POST'], path: `${ROUTE_PREFIX}/compat-exempt`, handler: routeCompatExempt },
46
- // 开发区根 + 钉住(0.5.57 加法):允许注册自己的根;不允许任意目录;agent 发起需用户勾选(默认关)
47
- { methods: ['POST'], path: `${ROUTE_PREFIX}/dev-roots`, handler: routeDevRoots },
48
- { methods: ['POST'], path: `${ROUTE_PREFIX}/pin-dev-dir`, handler: routePinDevDir },
48
+ // ★ 2026-10-18 改错(0.5.87):`dev-roots` / `pin-dev-dir` 原来登记在这张表里,而这张表的 handler
49
+ // **收不到 body**(`handle()` 在 earlyHit 分支里只传 ctx/url/pathname/method/deps,readBody 在它之后)。
50
+ // 真机后果:面板点「钉住」永远得到 `{"ok":false,"error":"没有提供目录"}`,注册开发区根也永远走 list 分支
51
+ // —— 典型的"接线了但死"。它们是 POST 写动作,本来就该在 readBody 之后 ⇒ 已移到下面的 ROUTES 表。
49
52
  { methods: ['GET'], path: `${ROUTE_PREFIX}/sources`, handler: routeSourcesGet },
50
53
  { methods: ['GET'], path: `${ROUTE_PREFIX}/gitee-oauth-url`, handler: routeGiteeOauthUrlGet },
51
54
  { methods: ['GET'], path: `${ROUTE_PREFIX}/gitee-oauth-callback`, handler: routeGiteeOauthCallbackGet },
@@ -127,6 +130,13 @@ const ROUTES = [
127
130
  { methods: ['POST'], path: `${ROUTE_PREFIX}/compat-stamp`, handler: routeCompatStamp },
128
131
  { methods: ['POST'], path: `${ROUTE_PREFIX}/compat-ack`, handler: routeCompatAck },
129
132
  { methods: ['POST'], path: `${ROUTE_PREFIX}/declare-installed`, handler: routeDeclareInstalled },
133
+ // 开发区根 + 钉住(0.5.57 加法):允许注册自己的根;不允许任意目录;agent 发起需用户勾选(默认关)
134
+ // ★ 0.5.87:从 ROUTES_EARLY 移到这里 —— 它们**吃 body**(见上方 ROUTES_EARLY 里的说明)。
135
+ { methods: ['POST'], path: `${ROUTE_PREFIX}/dev-roots`, handler: routeDevRoots },
136
+ { methods: ['POST'], path: `${ROUTE_PREFIX}/pin-dev-dir`, handler: routePinDevDir },
137
+ // link 面板的两个写动作(0.5.87 加法):补依赖/垫片(kind 指定做哪件)、取消钉住(两段式)
138
+ { methods: ['POST'], path: `${ROUTE_PREFIX}/link-repair`, handler: routeLinkRepair },
139
+ { methods: ['POST'], path: `${ROUTE_PREFIX}/unpin-dev-dir`, handler: routeUnpinDevDir },
130
140
  // 安全启动材料恢复入口(2026-09-29 加法 D3-①,0.5.32):白名单动作 list / restore-last-good /
131
141
  // disable-suspects / mark-good;只认动作名,绝不接受命令串;服务起不来时走 scripts/safe-boot.mjs
132
142
  { methods: ['POST'], path: `${ROUTE_PREFIX}/safe-boot`, handler: routeSafeBoot },