@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.
@@ -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 }
@@ -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 }
@@ -10,6 +10,9 @@ import { createRequire } from 'node:module'
10
10
  import { readCompatGate, writeCompatGate } from '../domain/compat.js'
11
11
  import { cleanupStaleFwTasks, currentFrameworkVersion, detectHostShape, pickFrameworkTarget, platformUpgradeRefusal, relaunchPrelude, resolveDshBin, shellHostedRefusal } from '../domain/framework.js'
12
12
  import { restartPathDecision } from '../domain/restart.js'
13
+ // 0.5.84 加法(用户第 5 项):独立实例的 detached 重拉 —— 计划在 domain/restart-launcher.js,
14
+ // 进程原语仍走 infra/egress.js 的可注入出口(单测能证明"一次真进程都没起")。
15
+ import { planRelaunch, relaunchDetached } from '../domain/restart-launcher.js'
13
16
  import { readGithubAuth } from '../domain/install.js'
14
17
  import { webPort } from '../domain/runtime.js'
15
18
  import { fetchJsonUrl } from '../infra/http.js'
@@ -45,6 +48,31 @@ function hostedHere(rc) {
45
48
  return rc.deps?.hosted ?? detectHostShape().hosted === true
46
49
  }
47
50
 
51
+ /** 解析 DSH 的 bin.js(**失败不抛**):拿不到就返回 null,判据据此回落"手动重启"。 */
52
+ function resolveDshBinSafe() {
53
+ try {
54
+ return resolveDshBin()
55
+ } catch {
56
+ return null
57
+ }
58
+ }
59
+
60
+ /** 本实例真实监听的端口(自定义 --port / 系统分配也对;拿不到返回 null)。 */
61
+ function webPortHard(rc) {
62
+ try {
63
+ return webPort(rc.ctx)
64
+ } catch {
65
+ return null
66
+ }
67
+ }
68
+
69
+ /** 优雅退出的默认实现:回完响应后结束本进程(**不 kill 任何进程**,端口随进程结束而释放)。 */
70
+ function defaultGracefulExit(delayMs) {
71
+ const timer = setTimeout(() => { process.exit(0) }, Math.max(0, Number(delayMs) || 0))
72
+ // 不阻止进程退出:这个定时器只是"晚一点退",不是"必须跑完"
73
+ if (typeof timer?.unref === 'function') timer.unref()
74
+ }
75
+
48
76
  /**
49
77
  * 平台守卫(2026-09-29 改错,与宿主守卫同形):一键升级/回滚脚本整体依赖 Windows 机制 ——
50
78
  * `schtasks.exe` 计划任务 + PowerShell + `Get-NetTCPConnection`;非 Windows 上 execFile 必然 ENOENT,
@@ -454,8 +482,41 @@ async function routeRestart(req, res, rc) {
454
482
  //
455
483
  // 注:清僵尸计划任务的既有能力**保留**(cleanupStaleFwTasks 见 lib/index.js 接线),
456
484
  // 只是控制台自己再也不新建这类任务了。
457
- const decision = restartPathDecision({ hosted: hostedHere(rc), intent: 'restart' })
458
- sendError(res, decision.status, decision.message, decision.details)
485
+ //
486
+ // ★ 0.5.84(用户第 5 项,**只在非 shell 托管时**):独立 dsh web 实例现在支持**真重启** ——
487
+ // detach 起一个新实例,老进程自己优雅退出。桌面端托管时判据照旧拒绝(那一半一个字没动)。
488
+ // 判据仍在 domain/restart.js(唯一承担者);这里只做三件事,顺序是刻意的:
489
+ // ① 先算重拉计划(拿不到 bin.js 就如实回落"手动重启",不硬来);
490
+ // ② 起启动器(detached,它自己等端口空了再起新实例 —— 于是没有 EADDRINUSE 竞态);
491
+ // ③ **先回响应**,再让本进程退出(不 kill 自己、不建计划任务)。
492
+ const hosted = hostedHere(rc)
493
+ // `rc.deps.relaunch` 是单测的确定性注入口(与 rc.deps.hosted 同一风格):
494
+ // 传 `null` = 模拟"拿不到 bin.js";不传 = 按真实解析(resolveDshBin + webPort)算。
495
+ const relaunchPlan = hosted === true ? null : (rc.deps?.relaunch !== undefined ? rc.deps.relaunch : planRelaunch({
496
+ binPath: resolveDshBinSafe(),
497
+ // 与真机那条原始命令同形:`node <bin.js> web`;端口显式带上(自定义 --port 也能原样重启)
498
+ args: (() => { const p = webPortHard(rc); return p === null ? ['web'] : ['web', '--port', String(p)] })(),
499
+ port: webPortHard(rc),
500
+ cwd: null,
501
+ }))
502
+ const decision = restartPathDecision({ hosted, intent: 'restart', relaunch: relaunchPlan })
503
+ if (decision.spawns !== true) {
504
+ sendError(res, decision.status, decision.message, decision.details)
505
+ return
506
+ }
507
+ const kicked = await relaunchDetached(relaunchPlan.plan, { spawn: rc.deps?.spawnDetached })
508
+ if (kicked.started !== true) {
509
+ // 起不来就**如实说**,绝不让面板以为"重启了"(也不会退出本进程——那样用户就真的没有实例了)
510
+ sendError(res, 500, `重启失败:没能拉起新进程(${kicked.error ?? '原因未知'})。本进程仍在运行,请手动重启。`, {
511
+ code: 'standalone-relaunch-failed', channel: decision.channel, spawns: false, kill: false, scheduledTask: false, selfDelete: false,
512
+ })
513
+ return
514
+ }
515
+ sendJson(res, 200, { ok: true, restarted: true, channel: decision.channel, pid: kicked.pid, message: decision.message, details: decision.details })
516
+ // ★ 先回响应、再退("优雅退出"):让端口随本进程结束而释放;启动器在那之后才起新实例。
517
+ // 延时是给响应刷出去的时间;delayedExit 可注入(单测绝不真的退出进程)。
518
+ const exit = typeof rc.deps?.delayedExit === 'function' ? rc.deps.delayedExit : defaultGracefulExit
519
+ exit(500)
459
520
  return
460
521
  }
461
522
 
@@ -20,6 +20,8 @@ import { routeCompatAck, routeCompatMode, routeCompatStamp, routeCompatStatusGet
20
20
  import { routeSafeBoot } from './safe-boot.js'
21
21
  import { routePatchAuditGet } from './patch-audit.js'
22
22
  import { routePresetAuditGet } from './preset-audit.js'
23
+ // 0.5.84 加法(用户第 7 项):<DSH_HOME>/plugin-console 的磁盘体检(只读)与**确认后**清理
24
+ import { routeDiskAudit, routeDiskCleanup } from './disk.js'
23
25
  import { routeFrameworkUpgrade } from './framework-upgrade.js'
24
26
  import { routeFrameworkPreflight, routeFrameworkPreflightPatch } from './framework-preflight.js'
25
27
  import { routeCheckUpdate, routeCompatGate, routeFrameworkCheck, routeFrameworkRelaunch, routeFrameworkRollback, routeFrameworkStatusClear, routeFrameworkUpgradeStatusGet, routeRestart } from './framework.js'
@@ -32,6 +34,8 @@ import { routeAdaptUnlock, routeAdaptUnlockAll, routeCleanResiduals, routeSelfUp
32
34
  import { routeSkillRemove, routeSkillToggle, routeSkillsInstalledGet } from './skills.js'
33
35
  import { routeGiteeOauthCallbackGet, routeGiteeOauthUrlGet, routeRegistryScan, routeSources, routeSourcesGet } from './sources.js'
34
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'
35
39
 
36
40
  const ROUTE_PREFIX = '/plugin-console'
37
41
 
@@ -41,9 +45,10 @@ const ROUTES_EARLY = [
41
45
  { methods: ['GET'], path: `${ROUTE_PREFIX}/links`, handler: routeLinksGet },
42
46
  // 兼容豁免(0.5.56 加法):把"被跳过的 bundle"的三条出路之一做成真正可点的动作
43
47
  { methods: ['POST'], path: `${ROUTE_PREFIX}/compat-exempt`, handler: routeCompatExempt },
44
- // 开发区根 + 钉住(0.5.57 加法):允许注册自己的根;不允许任意目录;agent 发起需用户勾选(默认关)
45
- { methods: ['POST'], path: `${ROUTE_PREFIX}/dev-roots`, handler: routeDevRoots },
46
- { 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 表。
47
52
  { methods: ['GET'], path: `${ROUTE_PREFIX}/sources`, handler: routeSourcesGet },
48
53
  { methods: ['GET'], path: `${ROUTE_PREFIX}/gitee-oauth-url`, handler: routeGiteeOauthUrlGet },
49
54
  { methods: ['GET'], path: `${ROUTE_PREFIX}/gitee-oauth-callback`, handler: routeGiteeOauthCallbackGet },
@@ -58,6 +63,8 @@ const ROUTES_EARLY = [
58
63
  // **任何声明 GET 的路由都必须登记在 ROUTES_EARLY**。该判据已固化进 tests/test-route-inventory.mjs。
59
64
  { methods: ['GET'], path: `${ROUTE_PREFIX}/patch-audit`, handler: routePatchAuditGet },
60
65
  { methods: ['GET'], path: `${ROUTE_PREFIX}/preset-audit`, handler: routePresetAuditGet },
66
+ // 0.5.84 加法(用户第 7 项):磁盘体检**只读** ⇒ 必须住在 405 守卫之前的表里(声明 GET 就要走得到)。
67
+ { methods: ['GET'], path: `${ROUTE_PREFIX}/disk-audit`, handler: routeDiskAudit },
61
68
  { methods: ['POST'], path: `${ROUTE_PREFIX}/framework-relaunch`, handler: routeFrameworkRelaunch },
62
69
 
63
70
  ]
@@ -123,6 +130,13 @@ const ROUTES = [
123
130
  { methods: ['POST'], path: `${ROUTE_PREFIX}/compat-stamp`, handler: routeCompatStamp },
124
131
  { methods: ['POST'], path: `${ROUTE_PREFIX}/compat-ack`, handler: routeCompatAck },
125
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 },
126
140
  // 安全启动材料恢复入口(2026-09-29 加法 D3-①,0.5.32):白名单动作 list / restore-last-good /
127
141
  // disable-suspects / mark-good;只认动作名,绝不接受命令串;服务起不来时走 scripts/safe-boot.mjs
128
142
  { methods: ['POST'], path: `${ROUTE_PREFIX}/safe-boot`, handler: routeSafeBoot },
@@ -161,6 +175,8 @@ const ROUTES = [
161
175
  // 依赖锁体检与重建(2026-09-27 加法):check 纯只读;repair 必须由用户显式调用
162
176
  { methods: ['POST'], path: `${ROUTE_PREFIX}/lockfile-check`, handler: routeLockfileCheck },
163
177
  { methods: ['POST'], path: `${ROUTE_PREFIX}/lockfile-repair`, handler: routeLockfileRepair },
178
+ // 0.5.84 加法(用户第 7 项):磁盘清理**默认只回计划**,confirm:true 才删(判据见 domain/disk-audit.js)
179
+ { methods: ['POST'], path: `${ROUTE_PREFIX}/disk-cleanup`, handler: routeDiskCleanup },
164
180
  // 结构化建议动作(2026-09-27 加法):只认白名单动作类型,argv 由服务端自拼(绝不接受命令字符串)
165
181
  { methods: ['POST'], path: `${ROUTE_PREFIX}/run-suggested`, handler: routeRunSuggested },
166
182
 
@@ -28,6 +28,11 @@ async function routeInstall(req, res, rc) {
28
28
  try { repo = githubRepoInfo(rawRepo) } catch {}
29
29
  }
30
30
  const givenName = typeof body.packageName === 'string' ? body.packageName.trim() : ''
31
+ // ★ 0.5.81(用户 2026-10-18):目标版本("更新并适配"必须装它,否则 pnpm 按 latest 原地重装旧版)。
32
+ // 严格限制形态:只接受 semver 样式的版本,别的一律忽略(不把任意字符串塞进 pnpm 规格)。
33
+ const requestedVersion = typeof body.version === 'string' && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/u.test(body.version.trim())
34
+ ? body.version.trim()
35
+ : null
31
36
  // 框架本体拦截:deepseek-harness 仓库与其根包 @deepseek-ai/dsh-root 是 DSH 框架自身,
32
37
  // 作为插件安装会试图构建整个框架源码——直接拒绝并提示
33
38
  if (repo === 'deepseek-ai/deepseek-harness'
@@ -94,6 +99,7 @@ async function routeInstall(req, res, rc) {
94
99
  subpackages: null,
95
100
  lastError: null,
96
101
  update: body.update === true,
102
+ version: requestedVersion, // ★ 目标版本(null = 照旧按 latest)
97
103
  kind,
98
104
  // 子目录插件安装(纯加法:不带 subdir 时这两项就是 null,作业形状与改动前逐字相同)
99
105
  subdir,
@@ -0,0 +1,120 @@
1
+ // L2 · routes —— link 面板的**两个写动作**(0.5.87 加法)
2
+ // POST /plugin-console/link-repair 缺什么补什么(kind: deps 补依赖 / shims 造垫片 / all 两件都做)
3
+ // POST /plugin-console/unpin-dev-dir 取消钉住(两段式:先只读计划,confirm:true 才移除)
4
+ //
5
+ // 为什么单独一个文件:`routes/state.js` 已经 439 行(上限 600),而这两条动作与「只读清单」
6
+ // 是两种职责(写 vs 读)。判据一律在 domain 层(link-self-sufficiency.js / unpin-dev-dir.js),
7
+ // 本文件只做三件事:**白名单校验 → 调 domain → 如实回包**。
8
+ //
9
+ // 安全语义(与 routes/revoke-force.js、routes/disk.js 同一套):
10
+ // · 包名必须是合法 npm 包名(NPM_NAME_RE),且**必须真的是本 profile 里的 link:/file: 依赖** ——
11
+ // 不接受任意包名,也不对 registry 依赖动手(那不是"开发式链接");
12
+ // · 路径**绝不从请求体来** —— profileDir 由服务端按当前 profile 决定;
13
+ // · 未确认(confirm!==true)时只回计划,一个字节都不写。
14
+ import { dirname } from 'node:path'
15
+ import { NPM_NAME_RE } from '../domain/dep-source.js'
16
+ import { pathDepsOf } from '../domain/link-inventory.js'
17
+ import { repairLinkTarget } from '../domain/link-self-sufficiency.js'
18
+ import { applyUnpinDevDir, planUnpinDevDir } from '../domain/unpin-dev-dir.js'
19
+ import { sendError, sendJson } from '../infra/httpd.js'
20
+ import { dshHome, findPatchPath } from '../infra/paths.js'
21
+
22
+ /** 当前 profile 根目录(拿不到就 null —— 调用方必须当成"拒绝改动"处理)。 */
23
+ function profileDirOfCtx(ctx) {
24
+ try { return dirname(findPatchPath(ctx)) } catch { return null }
25
+ }
26
+
27
+ /** 请求体 → 合法包名(不合法就空串)。 */
28
+ function packageNameOf(body) {
29
+ const raw = body !== null && typeof body === 'object' && typeof body.packageName === 'string' ? body.packageName.trim() : ''
30
+ return NPM_NAME_RE.test(raw) ? raw : ''
31
+ }
32
+
33
+ /**
34
+ * POST /link-repair —— 把"缺依赖 / 缺 peer"这两条出路做成可点动作。
35
+ * body: { packageName, kind?: 'deps' | 'shims' | 'all' }
36
+ */
37
+ async function routeLinkRepair(req, res, rc) {
38
+ const body = rc.body ?? {}
39
+ const packageName = packageNameOf(body)
40
+ if (packageName === '') { sendError(res, 400, '包名无效(只接受合法 npm 包名)'); return }
41
+ const kind = body.kind === 'deps' ? 'deps' : body.kind === 'shims' ? 'shims' : 'all'
42
+ const profileDir = profileDirOfCtx(rc.ctx)
43
+ if (typeof profileDir !== 'string' || profileDir === '') { sendError(res, 400, '拿不到 profile 目录,拒绝改动'); return }
44
+ // 只对**确实是本 profile 里的路径型依赖**生效(不凭空对任意包名动手)
45
+ const dep = pathDepsOf(profileDir).find((x) => x.packageName === packageName) ?? null
46
+ if (dep === null) {
47
+ sendError(res, 400, `${packageName} 不是本 profile 里的 link:/file: 依赖 —— 这份清单只处理开发式链接,不改别的东西`)
48
+ return
49
+ }
50
+ const result = await repairLinkTarget({ profileDir, packageName, kind, spec: dep.spec, home: dshHome() })
51
+ const plan = result.plan ?? null
52
+ const payload = {
53
+ ok: result.ok === true,
54
+ kind: result.kind,
55
+ packageName,
56
+ ready: result.ready === true,
57
+ changed: result.changed === true,
58
+ installed: result.installed === null || result.installed === undefined
59
+ ? null
60
+ : { ok: result.installed.ok === true, error: result.installed.error ?? null },
61
+ created: Array.isArray(result.created) ? result.created : [],
62
+ skipped: Array.isArray(result.skipped) ? result.skipped : [],
63
+ target: plan?.target ?? null,
64
+ missingDeps: plan?.missingDeps ?? [],
65
+ missingPeers: plan?.missingPeers ?? [],
66
+ shimmablePeers: plan?.shimmablePeers ?? [],
67
+ note: result.note ?? null,
68
+ error: result.ok === true ? null : (result.note ?? '补齐失败'),
69
+ }
70
+ if (result.ok === true) sendJson(res, 200, payload)
71
+ else sendError(res, 500, payload.error, payload)
72
+ }
73
+
74
+ /**
75
+ * POST /unpin-dev-dir —— 取消钉住(摘掉清单里那条 link:/file: 依赖)。
76
+ * body: { packageName, confirm?: boolean }
77
+ * confirm !== true → 只读计划(面板先给人看会怎样)
78
+ * confirm === true → 备份清单 → pnpm remove → **读回三处** → 失败即回滚
79
+ */
80
+ async function routeUnpinDevDir(req, res, rc) {
81
+ const body = rc.body ?? {}
82
+ const packageName = packageNameOf(body)
83
+ if (packageName === '') { sendError(res, 400, '包名无效(只接受合法 npm 包名)'); return }
84
+ const profileDir = profileDirOfCtx(rc.ctx)
85
+ if (typeof profileDir !== 'string' || profileDir === '') { sendError(res, 400, '拿不到 profile 目录,拒绝改动'); return }
86
+ if (body.confirm !== true) {
87
+ const plan = planUnpinDevDir({ profileDir, packageName })
88
+ if (plan.ok !== true) {
89
+ sendError(res, plan.status ?? 400, plan.error ?? '取消钉住规划失败', { ok: false, packageName, spec: plan.spec ?? null })
90
+ return
91
+ }
92
+ sendJson(res, 200, {
93
+ ok: true, applied: false, packageName, spec: plan.spec, target: plan.target,
94
+ targetExists: plan.targetExists, inBundles: plan.inBundles, bundles: plan.bundles,
95
+ warnings: plan.warnings, steps: plan.steps,
96
+ })
97
+ return
98
+ }
99
+ // 真删:用与 /uninstall 同一条 pnpm 通道(rc.deps.pnpmRemove),保持"一个动作负责三处"
100
+ const injected = rc.deps !== null && typeof rc.deps === 'object' && typeof rc.deps.pnpmRemove === 'function' ? rc.deps.pnpmRemove : null
101
+ const runRemove = injected === null ? null : (args) => injected(args.profileDir, args.packageName)
102
+ const result = await applyUnpinDevDir({ profileDir, packageName, runRemove })
103
+ const payload = {
104
+ ok: result.ok === true,
105
+ applied: result.changed === true,
106
+ packageName,
107
+ spec: result.spec ?? null,
108
+ target: result.target ?? null,
109
+ inBundles: result.inBundles === true,
110
+ removed: result.removed ?? null,
111
+ backup: result.backup ?? null,
112
+ warnings: result.warnings ?? [],
113
+ note: result.note ?? null,
114
+ error: result.ok === true ? null : (result.error ?? '取消钉住失败'),
115
+ }
116
+ if (result.ok === true) sendJson(res, 200, payload)
117
+ else sendError(res, result.status ?? 400, payload.error, payload)
118
+ }
119
+
120
+ export { routeLinkRepair, routeUnpinDevDir }
@@ -17,12 +17,15 @@ import { applyPinDevDir, planPinDevDir } from '../domain/pin-dev-dir.js'
17
17
  import { reconcileLockfile } from '../domain/selfupdate.js'
18
18
  import { ensureLinkSelfSufficiency } from '../domain/link-self-sufficiency.js'
19
19
  import { readBundlesSync } from '../domain/persist.js'
20
+ // 0.5.84 加法:清单 ↔ lock 口径不一致的**后台自动对账**(见 domain/lock-autofix.js 顶部三条硬约束)
21
+ import { lockAutofixStatus, scheduleLockAutofix } from '../domain/lock-autofix.js'
20
22
  import { failedJobEvidence } from '../domain/install-verify.js'
21
23
  import { installJobView, readGithubAuth } from '../domain/install.js'
22
24
  import { readPluginDetails } from '../domain/market.js'
23
25
  import { healPatchSafety, readPatchState } from '../domain/patch.js'
24
26
  import { pendingRestartJobs } from '../domain/revoke.js'
25
27
  import { mergePendingRestart } from '../domain/pending-restart.js'
28
+ import { recordInstallJob, readInstallJobs } from '../domain/install-jobs-store.js'
26
29
  import { listEntries } from '../domain/runtime.js'
27
30
  import { sendError, sendJson } from '../infra/httpd.js'
28
31
  import { dshHome, entryPkgMeta, findPatchPath, pluginRoot, profileDirOf, rowIdOf } from '../infra/paths.js'
@@ -146,6 +149,13 @@ async function routeStateGet(req, res, rc) {
146
149
  } catch {}
147
150
  return set
148
151
  })()
152
+ // ★ 0.5.83(用户 2026-10-18):作业结束后**落盘一条记录**(只记 done/failed,幂等),
153
+ // 宿主重启后仍能查到"上次更新干了什么、为什么失败"。写盘失败一律吞掉,绝不影响本接口。
154
+ try {
155
+ for (const job of installJobs.values()) {
156
+ if (job?.status === 'done' || job?.status === 'failed') recordInstallJob(job)
157
+ }
158
+ } catch {}
149
159
  let pendingRestart = []
150
160
  try { pendingRestart = mergePendingRestart(pendingRestartJobs([...installJobs.values()], listEntries(ctx)), livePackageNames) } catch { pendingRestart = pendingRestartJobs([...installJobs.values()], listEntries(ctx)) }
151
161
  // 框架升级检测与适配(备份快照 + 重打框架补丁),try 包裹不阻塞 state 返回
@@ -242,7 +252,19 @@ async function routeStateGet(req, res, rc) {
242
252
  patchUncertain: patchHeal === null ? [] : (patchHeal.uncertain ?? []),
243
253
  })
244
254
  } catch {}
245
- sendJson(res, 200, { ok: true, entries: allEntries, skippedBundles, host, health, patchPath, compat, installJobs: jobs, recentFailures, pendingRestart, github: { loggedIn: auth.loggedIn, login: auth.login }, patch: { disables: patch.disables, forced: patch.forced, inserts: patch.inserts }, framework, patchHeal: patchHeal === null ? null : { healed: patchHeal.healed ?? [], autoDisabled: patchHeal.autoDisabled ?? [], healedAt: patchHeal.healedAt ?? 0, written: patchHeal.written === true, writeError: patchHeal.writeError ?? null, skippedFrameworkOwned: (patchHeal.skipped ?? []).map((s) => s.id ?? null), uncertain: (patchHeal.uncertain ?? []).map((u) => u.id ?? null) }, gating: gatingSummary(ctx, compatPending, entries, compatVerdicts), compatPending: compatPending === null ? null : { frameworkVersion: compatPending.frameworkVersion ?? null, upgradeFrom: compatPending.upgradeFrom ?? null, pending: (compatPending.pending ?? []).filter((p) => (p.status ?? 'pending') === 'pending').map((p) => ({ rowId: p.rowId, moduleName: p.moduleName, version: p.version ?? null, checkNote: p.checkNote ?? null, check: p.check ?? null, riskyApprovedAt: p.riskyApprovedAt ?? null, adoptable: adoptable.get(p.rowId) ?? null, verdict: compatVerdicts.get(p.rowId) ?? null })), verdictRefresh: pendingState.refresh === null ? null : { version: pendingState.refresh.version ?? null, updated: pendingState.refresh.updated ?? [], unchanged: pendingState.refresh.unchanged ?? [], unresolved: pendingState.refresh.unresolved ?? [], wrote: pendingState.refresh.wrote === true, writeError: pendingState.refresh.writeError ?? null } }, compatGate, rollback: rollbackRec, selfVersion, components: findComponents().map((c) => ({ id: c.id, name: c.name, kind: c.kind ?? 'server', pid: c.pid ?? null, port: c.port ?? null, healthUrl: c.healthUrl ?? null, uiUrl: compUiUrl(c), autoStart: c.autoStart === true })) })
255
+ // ★ 0.5.84 加法(用户 2026-10):**后台自动对账 lock**。
256
+ // 为什么放在 /state:这里是唯一"每次开面板都会被调"的地方 —— 于是"清单与 lock 对不上"能被
257
+ // 及时发现,而不必等用户自己去点「重建 lock」。
258
+ // 为什么**不 await**:pnpm 是墙钟动作(实测 1.5s 起步、最坏分钟级),等它就会把 /state 拖死。
259
+ // 冷却窗 / 失败停手 / 在跑去重都在 domain/lock-autofix.js 里,这里只负责"问一句、不挡路"。
260
+ let lockAutofix = null
261
+ try {
262
+ const kicked = scheduleLockAutofix({ profileDir, registries: [] })
263
+ lockAutofix = { ...lockAutofixStatus({ profileDir }), scheduled: kicked.started, skippedReason: kicked.started ? null : kicked.reason }
264
+ } catch (error) {
265
+ lockAutofix = { error: error instanceof Error ? error.message : String(error), scheduled: false }
266
+ }
267
+ sendJson(res, 200, { ok: true, entries: allEntries, skippedBundles, host, health, patchPath, compat, installJobs: jobs, recentFailures, pendingRestart, github: { loggedIn: auth.loggedIn, login: auth.login }, patch: { disables: patch.disables, forced: patch.forced, inserts: patch.inserts }, framework, lockAutofix, patchHeal: patchHeal === null ? null : { healed: patchHeal.healed ?? [], autoDisabled: patchHeal.autoDisabled ?? [], healedAt: patchHeal.healedAt ?? 0, written: patchHeal.written === true, writeError: patchHeal.writeError ?? null, skippedFrameworkOwned: (patchHeal.skipped ?? []).map((s) => s.id ?? null), uncertain: (patchHeal.uncertain ?? []).map((u) => u.id ?? null) }, gating: gatingSummary(ctx, compatPending, entries, compatVerdicts), compatPending: compatPending === null ? null : { frameworkVersion: compatPending.frameworkVersion ?? null, upgradeFrom: compatPending.upgradeFrom ?? null, pending: (compatPending.pending ?? []).filter((p) => (p.status ?? 'pending') === 'pending').map((p) => ({ rowId: p.rowId, moduleName: p.moduleName, version: p.version ?? null, checkNote: p.checkNote ?? null, check: p.check ?? null, riskyApprovedAt: p.riskyApprovedAt ?? null, adoptable: adoptable.get(p.rowId) ?? null, verdict: compatVerdicts.get(p.rowId) ?? null })), verdictRefresh: pendingState.refresh === null ? null : { version: pendingState.refresh.version ?? null, updated: pendingState.refresh.updated ?? [], unchanged: pendingState.refresh.unchanged ?? [], unresolved: pendingState.refresh.unresolved ?? [], wrote: pendingState.refresh.wrote === true, writeError: pendingState.refresh.writeError ?? null } }, compatGate, rollback: rollbackRec, selfVersion, components: findComponents().map((c) => ({ id: c.id, name: c.name, kind: c.kind ?? 'server', pid: c.pid ?? null, port: c.port ?? null, healthUrl: c.healthUrl ?? null, uiUrl: compUiUrl(c), autoStart: c.autoStart === true })) })
246
268
  return
247
269
  }
248
270
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noob-stupid/dsh-plugin-console",
3
- "version": "0.5.77",
3
+ "version": "0.5.87",
4
4
  "description": "DSH 框架升级安全与插件升级门控:一键升级、失败自动回滚、升级后回滚上版、旧插件不适配自动禁用;内置多源插件市场为发现层,插件源全部可自定义,可指向公司内网私有源 / 私有索引 / 本地 Git 仓库,纯内网离线可用 | Framework upgrade safety & plugin version gating for DSH, with a customizable multi-source plugin market: point every source at internal mirrors or a local file:// repo for intranet-only, offline installs.",
5
5
  "repository": {
6
6
  "type": "git",