@noob-stupid/dsh-plugin-console 0.5.77 → 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.
@@ -42,6 +42,59 @@ import { dshHome, packageNameOf } from '../infra/paths.js'
42
42
  /** 三处缺口的**稳定名字**(面板文案与测试都按它们断言,不许改写)。 */
43
43
  const PERSIST_PARTS = ['dependency', 'lock', 'mount']
44
44
 
45
+ // ── 0.5.84 判据降噪:把「不一致」分成两类(**只分类,不改判据强度**)─────────────────
46
+ // 用户反馈:面板上到处是红字「未持久化」,但绝大多数并不影响使用 —— 看一眼就想关掉,
47
+ // 真正要紧的那条也跟着被无视了。所以这里给每个缺口定一个**机器可读的原因码**
48
+ // (`parts.*.code`),再按「这个不一致会不会让插件在重启后消失」分成两档:
49
+ //
50
+ // critical(会丢插件,必须红)—— 只有这两种:
51
+ // ① 清单里**没有**这个条目(或清单写的版本在磁盘上根本不存在):
52
+ // pnpm 下一次任何操作都会把这个"多余包"清掉 → 插件真的没了。
53
+ // ② 挂载行**不满足**:包在盘上,但重启后没有任何行会加载它 → 用户视角"插件消失了"。
54
+ // 注意这一条判的是"重启后会不会被挂上",与"包是不是已经下载"无关。
55
+ //
56
+ // warn(只是口径不一致,重启后照常加载,降级成可折叠提示)—— 其余全部:
57
+ // · lock 条目缺失 / 形态不符 / 指向的目标不同:**清单才是事实来源**,pnpm 会按清单重算 lock,
58
+ // 插件本身不会被清掉(最坏情况是 lock 被重写成同样的结论)。
59
+ // · 清单声明 link: 而磁盘是真实目录、或精确版本与磁盘版本不一致:包就在那里,
60
+ // pnpm 会把它改写成清单说的形态/版本 —— 换的是那一份副本,不是"没有"。
61
+ //
62
+ // 为什么要在**服务端**分(而不是让面板各自判):判据分两处必然分叉(本仓已多次踩过),
63
+ // 而"会不会丢"这件事需要同时看清单、lock 与挂载行的真实值 —— 只有这里同时握着这三份事实。
64
+ /** 会丢插件(必须红)的原因码。表里没有的码一律按 warn 处理(保守方向:宁可少报红)。 */
65
+ const PERSIST_CRITICAL_CODES = new Set([
66
+ 'dep-missing-entry', // 清单 dependencies 里没有它 → pnpm 会当多余包清掉
67
+ 'dep-link-broken', // 清单钉的 link 目标不存在 → 这份链接是断的,pnpm 只会把它当坏的
68
+ 'dep-version-missing', // 清单写了精确版本,但盘上根本没有这个包
69
+ 'dep-range-missing', // 清单写了范围/dist-tag,但盘上没有这个包
70
+ ])
71
+ /** 只是为了让人看得懂原因码说的是什么(面板不读它,测试与日志用它)。 */
72
+ const PERSIST_CODE_TEXT = {
73
+ 'dep-missing-entry': '清单 dependencies 里没有条目',
74
+ 'dep-link-broken': '清单钉的 link 目标不成立',
75
+ 'dep-version-missing': '盘上没有这个版本',
76
+ 'dep-range-missing': '盘上没有这个包',
77
+ 'dep-link-not-a-link': '清单是 link: 但盘上是真实目录',
78
+ 'dep-version-drift': '清单版本与磁盘版本不一致',
79
+ 'lock-missing-entry': 'lock 里没有条目',
80
+ 'lock-link-target-drift': 'lock 的 link 目标与清单不同',
81
+ 'lock-form-drift': 'lock 的形态与清单不同',
82
+ 'lock-version-drift': 'lock 的版本与清单不同',
83
+ 'lock-unverified': 'lock 写入后未被只读校验确认',
84
+ 'mount-bundle-no-patch': '在 bundles 里但没声明 bundle 补丁',
85
+ 'mount-bundle-patch-missing': '声明的 bundle 补丁文件不存在',
86
+ 'mount-bundle-patch-empty': 'bundle 补丁里没有任何 insert 行',
87
+ 'mount-no-row': '补丁/bundles/运行时都没有它的行',
88
+ }
89
+ /** `mount-*` 一律 critical(挂不上就是"重启后看不见");这里显式列出,避免靠前缀猜。 */
90
+ const PERSIST_CRITICAL_MOUNT = new Set(['mount-bundle-no-patch', 'mount-bundle-patch-missing', 'mount-bundle-patch-empty', 'mount-no-row'])
91
+
92
+ /** 某个原因码算不算"会丢插件"。 */
93
+ function isPersistCriticalCode(code) {
94
+ return PERSIST_CRITICAL_CODES.has(code) || PERSIST_CRITICAL_MOUNT.has(code)
95
+ }
96
+
97
+
45
98
  /** 精确版本号形态(`1.2.3` / `0.0.1-rc1`):只有它才要求与磁盘版本逐字相等。 */
46
99
  const EXACT_VERSION_RE = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/u
47
100
  /** 来源型 spec 前缀(协议)——这些形态**绝不**改写成版本号(改写等于丢来源)。 */
@@ -86,8 +139,9 @@ function diskFormOf(profileDir, packageName) {
86
139
  function dependencyPart({ profileDir, packageName }) {
87
140
  const spec = profileSpec(profileDir, packageName)
88
141
  const disk = diskFormOf(profileDir, packageName)
89
- const out = { ok: false, spec, form: null, disk: { form: disk.form, version: disk.version }, reason: null }
142
+ const out = { ok: false, spec, form: null, disk: { form: disk.form, version: disk.version }, reason: null, code: null }
90
143
  if (spec === null) {
144
+ out.code = 'dep-missing-entry'
91
145
  out.reason = 'package.json 的 dependencies 里没有这个条目(pnpm 会把它当多余包清掉)'
92
146
  return out
93
147
  }
@@ -95,10 +149,12 @@ function dependencyPart({ profileDir, packageName }) {
95
149
  if (trimmed.startsWith('link:')) {
96
150
  out.form = 'link'
97
151
  if (disk.form !== 'link') {
152
+ out.code = 'dep-link-not-a-link'
98
153
  out.reason = `清单是 ${trimmed},但 node_modules/${packageName} 不是指向它的链接(磁盘上实际是 ${disk.form === 'missing' ? '不存在' : '真实目录'})`
99
154
  return out
100
155
  }
101
156
  if (!linkSpecIsIntact(profileDir, packageName, trimmed)) {
157
+ out.code = 'dep-link-broken'
102
158
  out.reason = `清单是 ${trimmed},但链接当前不成立(目标不存在或指向别处)`
103
159
  return out
104
160
  }
@@ -114,10 +170,12 @@ function dependencyPart({ profileDir, packageName }) {
114
170
  if (EXACT_VERSION_RE.test(trimmed)) {
115
171
  out.form = 'version'
116
172
  if (disk.version === null) {
173
+ out.code = 'dep-version-missing'
117
174
  out.reason = '清单写的是版本号,但 node_modules 里读不到该包的版本(没装或被还原)'
118
175
  return out
119
176
  }
120
177
  if (disk.version !== trimmed) {
178
+ out.code = 'dep-version-drift'
121
179
  out.reason = `清单写 ${trimmed},磁盘上是 ${disk.version} —— 两处对不上`
122
180
  return out
123
181
  }
@@ -127,6 +185,7 @@ function dependencyPart({ profileDir, packageName }) {
127
185
  // 版本范围(^1.2.3)或 dist-tag(latest/next):registry 来源但非精确值 —— 有条目且装上了即算就位
128
186
  out.form = 'range'
129
187
  if (disk.version === null) {
188
+ out.code = 'dep-range-missing'
130
189
  out.reason = `清单写 ${trimmed},但 node_modules 里读不到该包(没装或被还原)`
131
190
  return out
132
191
  }
@@ -137,8 +196,9 @@ function dependencyPart({ profileDir, packageName }) {
137
196
  /** ② lock 条目:与 ① 的 spec 同一形态(link: ↔ link:;精确版本 ↔ 逐字相等)。 */
138
197
  function lockPart({ profileDir, packageName, spec }) {
139
198
  const entry = lockVersion(profileDir, packageName)
140
- const out = { ok: false, entry: entry ?? null, reason: null }
199
+ const out = { ok: false, entry: entry ?? null, reason: null, code: null }
141
200
  if (entry === null || entry === undefined) {
201
+ out.code = 'lock-missing-entry'
142
202
  out.reason = 'pnpm-lock.yaml 里没有这个包的条目(任何一次 pnpm 操作都可能把它还原/清掉)'
143
203
  return out
144
204
  }
@@ -151,6 +211,7 @@ function lockPart({ profileDir, packageName, spec }) {
151
211
  out.ok = true
152
212
  return out
153
213
  }
214
+ out.code = value.startsWith('link:') ? 'lock-link-target-drift' : 'lock-form-drift'
154
215
  out.reason = value.startsWith('link:')
155
216
  ? `清单把该包钉在 ${s},而 lock 里是 ${value}(**指向的目标不同**,对账后会被 pnpm 还原)`
156
217
  : `清单把该包钉在 ${s},而 lock 里是 ${value}(对账后会被 pnpm 还原)`
@@ -165,6 +226,7 @@ function lockPart({ profileDir, packageName, spec }) {
165
226
  out.ok = true
166
227
  return out
167
228
  }
229
+ out.code = 'lock-version-drift'
168
230
  out.reason = `清单写 ${s},lock 里是 ${value} —— 两处对不上`
169
231
  return out
170
232
  }
@@ -175,7 +237,7 @@ function lockPart({ profileDir, packageName, spec }) {
175
237
 
176
238
  /** ③ 挂载行:用户补丁 insert 行 / bundle 层 / 运行时已有行,三者任一成立即算会挂载。 */
177
239
  function mountPart({ profileDir, patchPath, packageName, patchText = null, bundles = null, entries = null }) {
178
- const out = { ok: false, via: 'none', rowId: null, bundlePatch: null, reason: null }
240
+ const out = { ok: false, via: 'none', rowId: null, bundlePatch: null, reason: null, code: null }
179
241
  // 运行时已有行提供它(例如聚合包里的行):最直接的证据,先看
180
242
  if (Array.isArray(entries)) {
181
243
  for (const entry of entries) {
@@ -208,12 +270,14 @@ function mountPart({ profileDir, patchPath, packageName, patchText = null, bundl
208
270
  rel = null
209
271
  }
210
272
  if (typeof rel !== 'string' || rel === '') {
273
+ out.code = 'mount-bundle-no-patch'
211
274
  out.reason = `已在 dsh.profile.bundles 里,但该包没有声明 dsh.bundle.patch —— bundle 层不会装入任何行,需要一条 insert 行`
212
275
  return out
213
276
  }
214
277
  const file = join(String(profileDir), 'node_modules', ...String(packageName).split('/'), rel)
215
278
  if (!existsSync(file)) {
216
279
  out.bundlePatch = file
280
+ out.code = 'mount-bundle-patch-missing'
217
281
  out.reason = `dsh.profile.bundles 里有它,但它声明的 bundle 补丁文件不存在(${file})`
218
282
  return out
219
283
  }
@@ -225,15 +289,64 @@ function mountPart({ profileDir, patchPath, packageName, patchText = null, bundl
225
289
  }
226
290
  if (rows.size === 0) {
227
291
  out.bundlePatch = file
292
+ out.code = 'mount-bundle-patch-empty'
228
293
  out.reason = `bundle 补丁里读不到任何 insert 行(${rel})—— 没有任何东西会被挂载`
229
294
  return out
230
295
  }
231
296
  return { ...out, ok: true, via: 'bundle', rowId: [...rows.keys()][0], bundlePatch: file }
232
297
  }
298
+ out.code = 'mount-no-row'
233
299
  out.reason = '用户补丁里没有它的 insert 行,dsh.profile.bundles 里也没有它 —— 重启后不会被挂载'
234
300
  return out
235
301
  }
236
302
 
303
+ /**
304
+ * 0.5.84 判据降噪的**唯一判据**:这次不一致会不会让插件在重启后消失?
305
+ * 输入可以是一份 `persistReport()` 的结果(只看 `parts` / `missing`),也可以是裸的 `parts`。
306
+ * 返回 `{ level, losesAfterRestart, critical, downgraded, codes, note }`:
307
+ * · `level: 'ok'` 三处齐备(不会多报红)
308
+ * · `level: 'critical'` 至少一处会丢插件(清单里没有 / 挂不上)→ 面板必须报红
309
+ * · `level: 'warn'` 只有口径不一致(lock 形态/目标、清单形态与磁盘不同)→ 降级成提示
310
+ * `note` 是**降级后**那句人话(critical 时与旧 persistNote 同一句,warn 时换软一点的措辞);
311
+ * 旧的 `report.note` / `persisted` / `suggestedPersistAction` **一个字都没动**(安装闸门语义不变)。
312
+ */
313
+ function persistSeverity(input) {
314
+ const parts = input !== null && typeof input === 'object' && input.parts !== undefined && input.parts !== null
315
+ ? input.parts
316
+ : (input !== null && typeof input === 'object' ? input : {})
317
+ const partsOf = {}
318
+ for (const key of PERSIST_PARTS) {
319
+ const part = parts[key]
320
+ partsOf[key] = part === undefined || part === null ? null : part
321
+ }
322
+ const openParts = PERSIST_PARTS.filter((key) => partsOf[key] !== null && partsOf[key].ok !== true)
323
+ const codes = {}
324
+ const critical = []
325
+ const downgraded = []
326
+ for (const key of openParts) {
327
+ const code = typeof partsOf[key].code === 'string' && partsOf[key].code !== '' ? partsOf[key].code : `${key}-unknown`
328
+ codes[key] = code
329
+ // 表里没有的码一律按 warn(保守方向:宁可少报红,也不拿红字打扰人)
330
+ if (isPersistCriticalCode(code)) critical.push(key)
331
+ else downgraded.push(key)
332
+ }
333
+ const losesAfterRestart = critical.length > 0
334
+ const level = openParts.length === 0 ? 'ok' : (losesAfterRestart ? 'critical' : 'warn')
335
+ const labels = { dependency: '① 清单 dependencies', lock: '② pnpm-lock.yaml', mount: '③ 挂载行(cordis.patch.yml / bundles)' }
336
+ let note = null
337
+ if (level === 'critical') {
338
+ const rows = critical.map((key) => `${labels[key]}:${(partsOf[key] && partsOf[key].reason) ?? PERSIST_CODE_TEXT[codes[key]] ?? '未就位'}`)
339
+ note = `未持久化(重启或任何一次 pnpm 操作后可能消失)—— ${rows.join(';')}`
340
+ if (downgraded.length > 0) {
341
+ note += `。另有 ${downgraded.length} 处口径不一致(不影响重启后加载):${downgraded.map((key) => labels[key] + ':' + ((partsOf[key] && partsOf[key].reason) ?? '不一致')).join(';')}`
342
+ }
343
+ } else if (level === 'warn') {
344
+ const rows = downgraded.map((key) => `${labels[key]}:${(partsOf[key] && partsOf[key].reason) ?? '不一致'}`)
345
+ note = `口径不一致(**不影响重启后加载**,只是清单 / lock 没对齐,下次 pnpm 操作会自行收敛)—— ${rows.join(';')}`
346
+ }
347
+ return { level, losesAfterRestart, critical, downgraded, codes, note }
348
+ }
349
+
237
350
  /**
238
351
  * 持久化判据(**只读**,永不抛):返回
239
352
  * `{ persisted, packageName, missing, parts:{dependency,lock,mount}, note }`。
@@ -259,6 +372,9 @@ function persistReport({ profileDir, patchPath, packageName, entries = null, pat
259
372
  },
260
373
  }
261
374
  report.note = report.persisted ? null : persistNote(report)
375
+ // 0.5.84 加法:同一份事实多一档"要不要报红"的结论(判据见 persistSeverity 顶部)。
376
+ // 旧的 persisted / note 保持原样 —— 这里只多一个字段,老调用点(与安装闸门)一个字都不用改。
377
+ report.severity = persistSeverity(report)
262
378
  return report
263
379
  }
264
380
 
@@ -338,6 +454,11 @@ async function ensurePersisted({ profileDir, patchPath, packageName, mode = 'ins
338
454
  ...(typeof sourceSpec === 'string' && sourceSpec !== '' ? { sourceSpec, sourceOrigin, sourceKind } : {}),
339
455
  ...(typeof deps.installSource === 'function' ? { installSource: deps.installSource } : {}),
340
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 } : {}),
341
462
  })
342
463
  if (declared.changed !== true) notes.push(`清单里已有这个包(实际是 ${declared.spec ?? disk.version})`)
343
464
  if (declared.depNote !== null && declared.depNote !== undefined) notes.push(declared.depNote)
@@ -399,6 +520,8 @@ async function ensurePersisted({ profileDir, patchPath, packageName, mode = 'ins
399
520
  after.missing = PERSIST_PARTS.filter((key) => after.parts[key].ok !== true)
400
521
  after.persisted = after.missing.length === 0
401
522
  after.note = after.persisted ? null : persistNote(after)
523
+ // 0.5.84:这一档判据也改了 parts(lock → ok:false),降噪结论必须跟着重算,否则会拿旧的
524
+ after.severity = persistSeverity(after)
402
525
  lockVerdict = { verified: lock.lockVerified, reason }
403
526
  }
404
527
  // 0.5.41 接线:真装失败时写入层会下发「一键真装」动作 —— 它优先于"钉住"(此时用户要的是真装那一步);
@@ -431,4 +554,6 @@ async function ensurePersisted({ profileDir, patchPath, packageName, mode = 'ins
431
554
  export {
432
555
  PERSIST_PARTS, EXACT_VERSION_RE, SOURCE_SPEC_RE, readManifestSync, readBundlesSync, diskFormOf,
433
556
  dependencyPart, lockPart, mountPart, persistReport, persistNote, suggestedPersistAction, ensurePersisted,
557
+ // 0.5.84 判据降噪:原因码表 + 唯一判据 + 判定函数(面板与测试都只认这一份)
558
+ PERSIST_CRITICAL_CODES, PERSIST_CRITICAL_MOUNT, PERSIST_CODE_TEXT, isPersistCriticalCode, persistSeverity,
434
559
  }
@@ -0,0 +1,110 @@
1
+ // L1 · domain —— restart-launcher.js(独立实例的**detached 重拉**:只起新进程,绝不动老进程;0.5.84 加法)
2
+ // 分层分组:L1 · domain(不认识 cordis ctx;进程原语经 infra/egress.js 的 spawnDetached 出口)
3
+ //
4
+ // ── 为什么需要它(用户 2026-10 的第 5 项)────────────────────────────────────────
5
+ // 「重启服务」过去在全仓是**一律拒绝**的:`restartPathDecision` 硬编码 spawns:false / kill:false。
6
+ // 那条红线来自 2026-10-10 的真机事故 —— 但事故的成因是**桌面端托管**时自杀 + 与宿主争端口
7
+ // (详见 domain/restart.js 顶部取证)。**独立 `dsh web` 实例**没有那个问题:它自己就是那个进程,
8
+ // 端口也归它;重启它只需要"起一个新进程 + 自己退出"。
9
+ // ⇒ 所以本模块只服务非 shell 托管的那一半,且只做两件事:
10
+ // ① 起一个 **detached** 的新实例(`node <bin.js> web [--port N]`);
11
+ // ② 老进程**自己优雅退出**(不 kill 自己、不建计划任务、不自删)—— 端口随之释放。
12
+ //
13
+ // ── 端口竞态(必须处理,否则新实例必然 EADDRINUSE)────────────────────────────────
14
+ // 父进程收到请求时端口还占着,而它要等响应发完才退出。所以新进程不能直接起 ——
15
+ // 由本模块起一个**极小的中间启动器**(`node -e <script>`,无临时文件):
16
+ // 轮询 127.0.0.1:port,直到连接被拒绝(= 端口空了)再 spawn 真实例;
17
+ // 最多等 30s,等不到就自己退出(绝不无限挂着一个轮询进程)。
18
+ // 计划通过 `DSH_RELAUNCH_PLAN` 环境变量传给启动器(不走 argv —— `node -e` 下的 argv 位置不直观)。
19
+ //
20
+ // ── 红线(一条不减)────────────────────────────────────────────────────────────────
21
+ // · 不 kill 任何进程(包括自己):`kill:false`、`scheduledTask:false`、`selfDelete:false`;
22
+ // · 只在 `host.hosted !== true` 时才会走到这里(判据在 domain/restart.js,唯一承担者);
23
+ // · 进程原语只经 infra/egress.js 的 `spawnDetached` 出口 ⇒ 单测注入替身后"一次真进程都没起"
24
+ // 是**可计数证明**的(tests/test-standalone-relaunch.mjs 就此断言)。
25
+ import { spawnDetached } from '../infra/egress.js'
26
+
27
+ /** 启动器最多等端口多久(毫秒)。 **有上限**:等不到就自己退出,不留悬挂轮询进程。 */
28
+ const PORT_WAIT_DEADLINE_MS = 30000
29
+ /** 轮询间隔(毫秒)。 */
30
+ const PORT_POLL_MS = 250
31
+
32
+ /**
33
+ * 启动器脚本(**纯文本**,单测可断言它只做"等端口 → 起新进程")。
34
+ * 读 `DSH_RELAUNCH_PLAN`(JSON: { bin, args, cwd, port })。
35
+ */
36
+ function launcherScript() {
37
+ return [
38
+ "const net = require('node:net')",
39
+ "const { spawn } = require('node:child_process')",
40
+ "const plan = JSON.parse(process.env.DSH_RELAUNCH_PLAN || '{}')",
41
+ 'const port = Number(plan.port) || 0',
42
+ 'const deadline = Date.now() + Number(plan.deadlineMs) || 0',
43
+ 'const poll = Number(plan.pollMs) || 250',
44
+ 'const freeProbe = () => new Promise((resolve) => {',
45
+ " const socket = net.connect({ host: '127.0.0.1', port: port })",
46
+ ' const settle = (free) => { try { socket.destroy() } catch { } ; resolve(free) }',
47
+ ' socket.setTimeout(1000, () => settle(false))',
48
+ ' socket.once(' + "'error'" + ', () => settle(true))',
49
+ ' socket.once(' + "'connect'" + ', () => settle(false))',
50
+ '})',
51
+ 'const start = () => {',
52
+ ' const child = spawn(process.execPath, [plan.bin].concat(plan.args || []), { detached: true, stdio: ' + "'ignore'" + ', windowsHide: true, cwd: plan.cwd || undefined })',
53
+ ' child.unref()',
54
+ ' process.exit(0)',
55
+ '}',
56
+ 'const tick = () => {',
57
+ ' if (Number.isFinite(deadline) && deadline > 0 && Date.now() > deadline) process.exit(1)',
58
+ ' freeProbe().then((free) => { if (free) start(); else setTimeout(tick, poll) })',
59
+ '}',
60
+ 'tick()',
61
+ ].join('\n')
62
+ }
63
+
64
+ /**
65
+ * 规划一次 detached 重拉(**纯函数**):argv 由调用方给(bin 路径由 routes 层按既有 `resolveDshBin` 解析)。
66
+ * @returns {{ ok: boolean, reason: string|null, plan: object|null }}
67
+ */
68
+ function planRelaunch({ binPath, args = [], port = null, cwd = null } = {}) {
69
+ const bin = typeof binPath === 'string' ? binPath.trim() : ''
70
+ if (bin === '') return { ok: false, reason: '找不到 DSH 的入口(bin.js),无法自动重启 —— 请手动重启这个 dsh web 进程', plan: null }
71
+ const argv = (Array.isArray(args) ? args : []).filter((a) => typeof a === 'string')
72
+ const numericPort = Number.isFinite(port) && port > 0 ? Math.floor(port) : null
73
+ return {
74
+ ok: true,
75
+ reason: null,
76
+ plan: {
77
+ bin,
78
+ args: argv,
79
+ cwd: typeof cwd === 'string' && cwd !== '' ? cwd : null,
80
+ port: numericPort,
81
+ deadlineMs: PORT_WAIT_DEADLINE_MS,
82
+ pollMs: PORT_POLL_MS,
83
+ },
84
+ }
85
+ }
86
+
87
+ /**
88
+ * 真的起那个启动器(**detached + unref**,父进程退出后继续活)。
89
+ * `deps.spawn` 只为单测注入(生产走 infra/egress.js 的 spawnDetached 出口)。
90
+ * @returns {Promise<{ started: boolean, pid: number|null, error: string|null }>}
91
+ */
92
+ async function relaunchDetached(plan, { spawn = spawnDetached, env = process.env } = {}) {
93
+ if (plan === null || typeof plan !== 'object' || typeof plan.bin !== 'string' || plan.bin === '') {
94
+ return { started: false, pid: null, error: '重拉计划无效(缺少 bin 路径),未起任何进程' }
95
+ }
96
+ if (typeof spawn !== 'function') return { started: false, pid: null, error: '进程出口不可用(spawnDetached 未接线)' }
97
+ const payload = JSON.stringify({ bin: plan.bin, args: plan.args ?? [], cwd: plan.cwd ?? null, port: plan.port ?? null, deadlineMs: plan.deadlineMs ?? PORT_WAIT_DEADLINE_MS, pollMs: plan.pollMs ?? PORT_POLL_MS })
98
+ try {
99
+ const out = await spawn(process.execPath, ['-e', launcherScript()], {
100
+ env: { ...env, DSH_RELAUNCH_PLAN: payload },
101
+ cwd: plan.cwd ?? undefined,
102
+ detached: true,
103
+ })
104
+ return { started: true, pid: out !== null && typeof out?.pid === 'number' ? out.pid : null, error: null }
105
+ } catch (error) {
106
+ return { started: false, pid: null, error: error instanceof Error ? error.message : String(error) }
107
+ }
108
+ }
109
+
110
+ export { PORT_POLL_MS, PORT_WAIT_DEADLINE_MS, launcherScript, planRelaunch, relaunchDetached }
@@ -61,8 +61,14 @@ const STANDALONE_RESTART_NOTICE = 'DSH 没有可由插件调用的官方重启
61
61
  /** `/framework-relaunch`(原「手动拉起服务」悬浮按钮):同一判据,另加"这条路已下线"的说明。 */
62
62
  const MANUAL_LAUNCH_REMOVED_NOTE = '「手动拉起服务」已按官方路径下线(控制台不再 spawn 新的 dsh 实例):'
63
63
 
64
- /** 两条官方判定通道(稳定字符串,前端与测试按它判因)。 */
65
- const RESTART_CHANNEL = { desktopClient: 'desktop-client', unavailable: 'unavailable' }
64
+ /** 两条官方判定通道(稳定字符串,前端与测试按它判因)。
65
+ * 0.5.84 加法:新增 `standaloneRelaunch` —— **只有独立实例**(非 shell 托管)才走这条真重启;
66
+ * 桌面端托管仍然走 desktop-client(拒绝 + 说明),那条一个字没动。 */
67
+ const RESTART_CHANNEL = { desktopClient: 'desktop-client', standaloneRelaunch: 'standalone-relaunch', unavailable: 'unavailable' }
68
+
69
+ /** 独立实例真的重启成功后的文案(**先起新进程、老进程自己优雅退出**)。 */
70
+ const STANDALONE_RELAUNCH_NOTICE = '正在重启:已 detached 拉起一个新的 dsh web 进程,本进程即将优雅退出(不 kill 任何进程、不建计划任务);'
71
+ + '新实例会等端口释放后启动,稍等几秒刷新页面即可。'
66
72
 
67
73
  /** 面向用户的出路(结构化下发,前端可原样展示;桌面端与独立实例各自一条真出路)。 */
68
74
  const GUIDES = {
@@ -71,6 +77,11 @@ const GUIDES = {
71
77
  '已弹出「应用无法启动或已意外停止」时:直接点弹窗里的「重启」(官方恢复对话框)。',
72
78
  '只是想让插件改动生效:按 DSH 官方语义"下次启动生效"(restart-required),刷新页面即可看到已生效的部分。',
73
79
  ],
80
+ 'standalone-relaunch': [
81
+ '新实例会在老进程退出、端口释放后自动启动(启动器最多等 30 秒)。',
82
+ '若几秒后页面仍然连不上:回到启动它的终端看输出,必要时手动运行 dsh web。',
83
+ '控制台绝不会 kill 进程、绝不建计划任务:失败时如实告知,不制造幽灵实例。',
84
+ ],
74
85
  unavailable: [
75
86
  '独立 dsh web:在启动它的终端里 Ctrl+C,然后重新运行 dsh web。',
76
87
  '只改了客户端插件:DSH 官方语义是"下次启动生效"(restart-required),直接刷新页面。',
@@ -85,17 +96,80 @@ const GUIDES = {
85
96
  * @param {boolean} p.hosted 是否由桌面端外壳托管(调用方用 domain/framework.js#detectHostShape 判定后传入;
86
97
  * 本函数不重复判定,保证与 shellHostedRefusal 同一份事实)
87
98
  * @param {'restart'|'relaunch'} [p.intent] 控制台里的哪颗按钮(只影响文案前缀,判定完全相同)
88
- * @returns {{ ok: false, official: boolean, channel: string, spawns: false, kill: false,
99
+ * @param {object|null} [p.relaunch] **独立实例**的重拉计划(domain/restart-launcher.js#planRelaunch 的产物)。
100
+ * 给了且 ok ⇒ 走 standalone-relaunch(真起一个 detached 新进程);
101
+ * 没给 / 不 ok ⇒ 回落 unavailable(如实告知手动重启)。
102
+ * ⚠️ 桌面端托管时本参数**被忽略**:那条路永远不 spawn(用户红线)。
103
+ * @returns {{ ok: boolean, official: boolean, channel: string, spawns: boolean, kill: false,
89
104
  * status: number, code: string, message: string, details: object }}
90
105
  */
91
- function restartPathDecision({ hosted, intent = 'restart' } = {}) {
106
+ function restartPathDecision({ hosted, intent = 'restart', relaunch = null } = {}) {
92
107
  const shellHosted = hosted === true
93
- const channel = shellHosted ? RESTART_CHANNEL.desktopClient : RESTART_CHANNEL.unavailable
94
- const base = shellHosted ? DESKTOP_CLIENT_RESTART_NOTICE : STANDALONE_RESTART_NOTICE
108
+ // ★ 桌面端托管:判据与文案与 2026-10-10 那次改错**逐字一致**(不接受任何 relaunch 计划)
109
+ if (shellHosted) {
110
+ const channel = RESTART_CHANNEL.desktopClient
111
+ const message = intent === 'relaunch' ? MANUAL_LAUNCH_REMOVED_NOTE + DESKTOP_CLIENT_RESTART_NOTICE : DESKTOP_CLIENT_RESTART_NOTICE
112
+ return {
113
+ ok: false,
114
+ official: true,
115
+ channel,
116
+ spawns: false,
117
+ kill: false,
118
+ status: 409,
119
+ code: `official-restart-${channel}`,
120
+ message,
121
+ details: {
122
+ code: `official-restart-${channel}`,
123
+ official: true,
124
+ channel,
125
+ // 机器可读的红线判据:桌面端托管时这三项永远是 false(测试的负控就钉在这里)
126
+ spawns: false,
127
+ kill: false,
128
+ scheduledTask: false,
129
+ selfDelete: false,
130
+ manualLaunch: intent === 'relaunch' ? 'removed' : 'never',
131
+ guide: GUIDES[channel],
132
+ },
133
+ }
134
+ }
135
+ // ★ 独立实例 + 有可用重拉计划 ⇒ **真重启**:起一个新进程,老进程自己优雅退出。
136
+ // 仍然不 kill / 不建计划任务 / 不自删 —— 用户那次事故的根因是"自杀 + 争端口",
137
+ // 而独立实例既不需要自杀(它自己退出就行),也不存在争端口(启动器等端口空了才起新实例)。
138
+ const plan = relaunch !== null && typeof relaunch === 'object' && relaunch.ok === true ? relaunch.plan ?? null : null
139
+ if (plan !== null && typeof plan === 'object' && typeof plan.bin === 'string' && plan.bin !== '') {
140
+ const channel = RESTART_CHANNEL.standaloneRelaunch
141
+ return {
142
+ ok: true,
143
+ official: false,
144
+ channel,
145
+ spawns: true,
146
+ kill: false,
147
+ status: 200,
148
+ code: channel,
149
+ message: STANDALONE_RELAUNCH_NOTICE,
150
+ details: {
151
+ code: channel,
152
+ official: false,
153
+ channel,
154
+ spawns: true,
155
+ kill: false,
156
+ scheduledTask: false,
157
+ selfDelete: false,
158
+ detached: true,
159
+ gracefulExit: true,
160
+ manualLaunch: intent === 'relaunch' ? 'removed' : 'never',
161
+ plan: { bin: plan.bin, args: Array.isArray(plan.args) ? plan.args : [], port: plan.port ?? null, cwd: plan.cwd ?? null },
162
+ guide: GUIDES[channel],
163
+ },
164
+ }
165
+ }
166
+ // ★ 独立实例但拿不到重拉计划(例如找不到 bin.js)⇒ 与旧行为一致:如实告知手动重启
167
+ const channel = RESTART_CHANNEL.unavailable
168
+ const base = typeof relaunch?.reason === 'string' && relaunch.reason !== '' ? `${relaunch.reason}。${STANDALONE_RESTART_NOTICE}` : STANDALONE_RESTART_NOTICE
95
169
  const message = intent === 'relaunch' ? MANUAL_LAUNCH_REMOVED_NOTE + base : base
96
170
  return {
97
171
  ok: false,
98
- official: shellHosted,
172
+ official: false,
99
173
  channel,
100
174
  spawns: false,
101
175
  kill: false,
@@ -104,9 +178,9 @@ function restartPathDecision({ hosted, intent = 'restart' } = {}) {
104
178
  message,
105
179
  details: {
106
180
  code: `official-restart-${channel}`,
107
- official: shellHosted,
181
+ official: false,
108
182
  channel,
109
- // 机器可读的红线判据:这两项永远是 false(测试的负控就钉在这里)
183
+ // 机器可读的红线判据:这三项永远是 false
110
184
  spawns: false,
111
185
  kill: false,
112
186
  scheduledTask: false,
@@ -117,4 +191,4 @@ function restartPathDecision({ hosted, intent = 'restart' } = {}) {
117
191
  }
118
192
  }
119
193
 
120
- export { DESKTOP_CLIENT_RESTART_NOTICE, MANUAL_LAUNCH_REMOVED_NOTE, RESTART_CHANNEL, STANDALONE_RESTART_NOTICE, restartPathDecision }
194
+ export { DESKTOP_CLIENT_RESTART_NOTICE, MANUAL_LAUNCH_REMOVED_NOTE, RESTART_CHANNEL, STANDALONE_RELAUNCH_NOTICE, STANDALONE_RESTART_NOTICE, restartPathDecision }
@@ -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
  }
@@ -13,17 +13,21 @@ import { dshHome } from '../infra/paths.js'
13
13
  /** 探测仓库是否为技能仓库:根目录或第一层子目录存在 SKILL.md。
14
14
  * 返回 { hasSkill, skillDir }(skillDir 为相对仓库根的目录,'' 表示根)。
15
15
  * raw 双通道竞速,3 秒封顶,失败静默。 */
16
- async function detectSkillRepo(repo, branch = 'main') {
16
+ async function detectSkillRepo(repo, branch = 'main', deps = {}) {
17
+ // deps 只为**富化路径**(domain/market.js)注入限流出口而加;生产的老调用点都不传,
18
+ // 于是回落值与改动前逐字相同(curlText / curlJson)。
19
+ const fetchText = typeof deps.curlText === 'function' ? deps.curlText : curlText
20
+ const fetchJson = typeof deps.curlJson === 'function' ? deps.curlJson : curlJson
17
21
  const branchEnc = encodeURIComponent(branch)
18
22
  try {
19
23
  const root = await Promise.any([
20
- curlText(`${GITHUB_RAW}/${repo}/${branchEnc}/SKILL.md`, 3000),
21
- curlText(`https://ghproxy.net/${GITHUB_RAW}/${repo}/${branchEnc}/SKILL.md`, 3000),
22
- curlText(`https://cdn.jsdelivr.net/gh/${repo}@${branchEnc}/SKILL.md`, 3000),
24
+ fetchText(`${GITHUB_RAW}/${repo}/${branchEnc}/SKILL.md`, 3000),
25
+ fetchText(`https://ghproxy.net/${GITHUB_RAW}/${repo}/${branchEnc}/SKILL.md`, 3000),
26
+ fetchText(`https://cdn.jsdelivr.net/gh/${repo}@${branchEnc}/SKILL.md`, 3000),
23
27
  ]).catch(() => null)
24
28
  if (root !== null) return { hasSkill: true, skillDir: '' }
25
29
  // 根没有时再查一层子目录(常用布局:skills/<name>/SKILL.md、<name>/SKILL.md)
26
- const tree = await curlJson(`https://api.github.com/repos/${repo}/git/trees/${branchEnc}?recursive=1`, 5000).catch(() => null)
30
+ const tree = await fetchJson(`https://api.github.com/repos/${repo}/git/trees/${branchEnc}?recursive=1`, 5000).catch(() => null)
27
31
  const skillPaths = (tree?.tree ?? [])
28
32
  .filter((n) => n.type === 'blob' && /(?:^|\/)SKILL\.md$/u.test(n.path))
29
33
  .map((n) => n.path)