@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.
@@ -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'
@@ -58,6 +60,8 @@ const ROUTES_EARLY = [
58
60
  // **任何声明 GET 的路由都必须登记在 ROUTES_EARLY**。该判据已固化进 tests/test-route-inventory.mjs。
59
61
  { methods: ['GET'], path: `${ROUTE_PREFIX}/patch-audit`, handler: routePatchAuditGet },
60
62
  { methods: ['GET'], path: `${ROUTE_PREFIX}/preset-audit`, handler: routePresetAuditGet },
63
+ // 0.5.84 加法(用户第 7 项):磁盘体检**只读** ⇒ 必须住在 405 守卫之前的表里(声明 GET 就要走得到)。
64
+ { methods: ['GET'], path: `${ROUTE_PREFIX}/disk-audit`, handler: routeDiskAudit },
61
65
  { methods: ['POST'], path: `${ROUTE_PREFIX}/framework-relaunch`, handler: routeFrameworkRelaunch },
62
66
 
63
67
  ]
@@ -161,6 +165,8 @@ const ROUTES = [
161
165
  // 依赖锁体检与重建(2026-09-27 加法):check 纯只读;repair 必须由用户显式调用
162
166
  { methods: ['POST'], path: `${ROUTE_PREFIX}/lockfile-check`, handler: routeLockfileCheck },
163
167
  { methods: ['POST'], path: `${ROUTE_PREFIX}/lockfile-repair`, handler: routeLockfileRepair },
168
+ // 0.5.84 加法(用户第 7 项):磁盘清理**默认只回计划**,confirm:true 才删(判据见 domain/disk-audit.js)
169
+ { methods: ['POST'], path: `${ROUTE_PREFIX}/disk-cleanup`, handler: routeDiskCleanup },
164
170
  // 结构化建议动作(2026-09-27 加法):只认白名单动作类型,argv 由服务端自拼(绝不接受命令字符串)
165
171
  { methods: ['POST'], path: `${ROUTE_PREFIX}/run-suggested`, handler: routeRunSuggested },
166
172
 
@@ -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,
@@ -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.85",
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",