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

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
 
@@ -399,6 +515,8 @@ async function ensurePersisted({ profileDir, patchPath, packageName, mode = 'ins
399
515
  after.missing = PERSIST_PARTS.filter((key) => after.parts[key].ok !== true)
400
516
  after.persisted = after.missing.length === 0
401
517
  after.note = after.persisted ? null : persistNote(after)
518
+ // 0.5.84:这一档判据也改了 parts(lock → ok:false),降噪结论必须跟着重算,否则会拿旧的
519
+ after.severity = persistSeverity(after)
402
520
  lockVerdict = { verified: lock.lockVerified, reason }
403
521
  }
404
522
  // 0.5.41 接线:真装失败时写入层会下发「一键真装」动作 —— 它优先于"钉住"(此时用户要的是真装那一步);
@@ -431,4 +549,6 @@ async function ensurePersisted({ profileDir, patchPath, packageName, mode = 'ins
431
549
  export {
432
550
  PERSIST_PARTS, EXACT_VERSION_RE, SOURCE_SPEC_RE, readManifestSync, readBundlesSync, diskFormOf,
433
551
  dependencyPart, lockPart, mountPart, persistReport, persistNote, suggestedPersistAction, ensurePersisted,
552
+ // 0.5.84 判据降噪:原因码表 + 唯一判据 + 判定函数(面板与测试都只认这一份)
553
+ PERSIST_CRITICAL_CODES, PERSIST_CRITICAL_MOUNT, PERSIST_CODE_TEXT, isPersistCriticalCode, persistSeverity,
434
554
  }
@@ -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 }
@@ -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)
@@ -23,7 +23,7 @@
23
23
  //
24
24
  // 分层:L0 · infra(不得 import domain/routes/index —— tests/test-architecture-guard.mjs 断言②)。
25
25
 
26
- import { execFile as nodeExecFile } from 'node:child_process'
26
+ import { execFile as nodeExecFile, spawn as nodeSpawn } from 'node:child_process'
27
27
  import { request as nodeHttpsRequest } from 'node:https'
28
28
  import { promisify } from 'node:util'
29
29
 
@@ -202,7 +202,40 @@ function egressHttpsRequestOnce(url, options) {
202
202
  return nodeHttpsRequest(url, options)
203
203
  }
204
204
 
205
+ // ── 0.5.84 加法:detached 拉起(独立实例的"真重启")──────────────────────────────
206
+ // 为什么放在这里而不是 domain/restart-launcher.js 里直接 spawn:
207
+ // 全仓的规矩是「进程原语只有 egress.js 认识」(tests/test-repo-manifest-channels.mjs ★⑨ 静态断言)。
208
+ // 于是新增的 detached spawn 也必须是这一层的**可注入出口**:单测 `__setEgressStub({ spawnDetached })`
209
+ // 之后,"一次真进程都没起"能被计数证明,而不是靠读代码相信。
210
+ // 语义:**只起一个新进程,绝不动老进程**(不 kill、不自杀、不建计划任务)——
211
+ // 调用方(routes/framework.js)负责"先回响应、再自己优雅退出",把端口让给新实例。
212
+ function egressSpawnDetachedReal(command, args = [], options = {}) {
213
+ const child = nodeSpawn(command, args, {
214
+ detached: true,
215
+ stdio: 'ignore',
216
+ windowsHide: true,
217
+ cwd: typeof options.cwd === 'string' && options.cwd !== '' ? options.cwd : undefined,
218
+ env: options.env !== undefined && options.env !== null ? options.env : process.env,
219
+ })
220
+ // 脱离父进程:父进程退出后子进程继续跑(这是"重启"能成立的前提)
221
+ child.unref()
222
+ return { pid: typeof child.pid === 'number' ? child.pid : null, detached: true }
223
+ }
224
+ /** 出口包装:stub 优先 → 记一笔 spawn → **真实现登记表**(不直接给 fallback!)。
225
+ * 为什么不给 fallback:给了就绕过了下面的 `unwired()` —— tripwire 与 gaps 全部失效,
226
+ * 测试进程里会**真的**起一个 detached 进程(写这条时实测踩到:给 fallback 的那版在单测里真 spawn 了)。
227
+ * 所以按本仓既有形态:真实现登记进 realImpl,出口只查表。 */
228
+ const spawnDetached = makeEgress('spawnDetached', 'spawn')
229
+ __setEgressRealImpl({ spawnDetached: egressSpawnDetachedReal })
230
+
231
+ // 0.5.84 加法:把「本进程是测试进程」这一条判据**导出**(原本只在本文件内用)。
232
+ // 用途:domain/lock-autofix.js 的**后台自动对账**在测试进程里一律不启动 ——
233
+ // 它会真的跑 pnpm 并重写 fixture 的 pnpm-lock.yaml,而多套测试对"lock 是否被写过"有断言。
234
+ // 判据只有这一份(看 process.argv[1] 是不是 tests/**),测试无法自己解开它。
235
+ // (注:本段说明放在 export 块**外面** —— 架构守卫 ⑥ 的导出名解析不跳过块内注释。)
205
236
  export {
206
237
  __setEgressStub, __setEgressTripwire, __setEgressRealImpl, __resetEgressStats, egressStatsNow,
207
238
  makeEgress, egressHttpsRequestOnce, egressSelfCheckSpawn, egressSelfCheckNetwork,
239
+ spawnDetached,
240
+ isTestProcess,
208
241
  }
@@ -0,0 +1,59 @@
1
+ // L2 · routes —— `<DSH_HOME>/plugin-console` 的**磁盘体检 / 清理**(0.5.84 加法,用户第 7 项)
2
+ // GET /plugin-console/disk-audit 只读体检:谁占了多少、哪些可以清、哪些绝不能动
3
+ // POST /plugin-console/disk-cleanup **必须显式确认**(confirm:true)才删;未确认只回计划
4
+ //
5
+ // 用户原话(大意):这个目录已经 2.1 GB(framework-backups 含全平台 libreoffice),
6
+ // 要一个"只读体检 + 显式确认后清理"的能力,**绝不自动删除**。
7
+ //
8
+ // 安全语义(写死,判据全在 domain/disk-audit.js):
9
+ // · audit 只统计,不删不写;
10
+ // · cleanup 只认体检结果里出现过的条目名(拒绝路径穿越),且 protected(当前回滚点 /
11
+ // 控制台状态文件 / 未知条目)一律拒绝并说明原因;
12
+ // · 删完**读回核实**,没删干净如实回报(Windows 上 rmSync 会静默落空,本仓 fsx.js 记过)。
13
+ //
14
+ // 真机实测(2026-10):最大的那 2.1 GB 是 `framework-backups/0.2.0-rc.1`,而它**正是**
15
+ // `framework-rollback.json` 的 checkpointDir ⇒ 判据从回滚记录里读,绝不按体积猜"大的就可以删"。
16
+
17
+ import { auditConsoleDir, runCleanup } from '../domain/disk-audit.js'
18
+ import { sendError, sendJson } from '../infra/httpd.js'
19
+ import { dshHome } from '../infra/paths.js'
20
+ import { join } from 'node:path'
21
+
22
+ /** 体检/清理的唯一根目录(绝不接受调用方传路径 —— 那等于开了一个任意路径删除的口子)。 */
23
+ function consoleDir() {
24
+ return join(dshHome(), 'plugin-console')
25
+ }
26
+
27
+ async function routeDiskAudit(req, res, rc) {
28
+ const audit = auditConsoleDir({ root: consoleDir(), deps: rc.deps?.diskAuditDeps ?? {} })
29
+ if (audit.ok !== true) {
30
+ sendError(res, 500, `磁盘体检失败:${audit.error ?? '未知原因'}`, { ok: false, entries: [] })
31
+ return
32
+ }
33
+ sendJson(res, 200, audit)
34
+ }
35
+
36
+ async function routeDiskCleanup(req, res, rc) {
37
+ const body = rc.body ?? {}
38
+ const ids = Array.isArray(body.ids) ? body.ids : (typeof body.ids === 'string' ? [body.ids] : [])
39
+ const root = consoleDir()
40
+ // 每次都**重新体检**(不信任客户端回传的体积/分级 —— 那等于让调用方决定删什么)
41
+ const audit = auditConsoleDir({ root, deps: rc.deps?.diskAuditDeps ?? {} })
42
+ if (audit.ok !== true) {
43
+ sendError(res, 500, `磁盘体检失败:${audit.error ?? '未知原因'}`, { ok: false })
44
+ return
45
+ }
46
+ const result = runCleanup({ root, audit, ids, confirm: body.confirm === true, deps: rc.deps?.diskCleanupDeps ?? {} })
47
+ if (result.needsConfirm === true) {
48
+ // 未确认:200 + 计划(不是错误 —— 面板要用这份计划渲染「确认清理」按钮与体积)
49
+ sendJson(res, 200, { ok: true, needsConfirm: true, applied: false, plan: result.plan, note: result.note })
50
+ return
51
+ }
52
+ if (result.plan?.refused?.length > 0 && result.removed?.length === 0) {
53
+ sendError(res, 400, `没有可以清理的条目:${result.plan.refused.map((r) => `${r.id}(${r.reason})`).join(';')}`, { ok: false, applied: false, refused: result.plan.refused })
54
+ return
55
+ }
56
+ sendJson(res, 200, result)
57
+ }
58
+
59
+ export { routeDiskAudit, routeDiskCleanup }