@noob-stupid/dsh-plugin-console 0.5.46 → 0.5.48

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,64 @@
1
+ // L0 · infra —— 「本机自带那份 pnpm 在哪」的候选发现(0.5.48 加法;从 exec.js 抽出)
2
+ //
3
+ // 为什么单开一个模块:exec.js 贴着 tests/test-architecture-guard.mjs 的 **单文件 ≤ 600 行**硬顶,
4
+ // 而"从桌面端运行时 / DSH 运行时装 / PATH 里找出那份 pnpm"是自洽的一块只读探测逻辑。
5
+ //
6
+ // 两类被发现的 pnpm(形态不同,**绝不能混**):
7
+ // · `desktop-pnpm-mjs`:桌面端(Electron)自带运行时那份。用 **Electron 二进制**跑,
8
+ // 必须显式带 `ELECTRON_RUN_AS_NODE=1` —— 不带它,Electron 会当 **GUI 应用**启动
9
+ // (等于弹一个新窗口,而且退出码可能是 0,"假成功"比失败更糟)。
10
+ // · `runtime-pnpm-mjs`:DSH **运行时装**那份(0.5.48 新增)。用普通 **node** 跑,**不带**那个 env。
11
+ //
12
+ // ★ 为什么必须有 `runtime-pnpm-mjs`(这条就是缺陷②的正解):
13
+ // 原来只有两条线索能找到本机那份 pnpm —— ① Electron 的 `process.resourcesPath`;
14
+ // ② PATH 里的 `<…>\runtime\pnpm\bin` / `<…>\runtime\bin`。**两条都只在桌面端宿主成立**。
15
+ // 而控制台的安装动作跑在**普通 node 服务进程**里 ⇒ 两条都没有 ⇒ 落到候选表最前面的
16
+ // `node-corepack` ⇒ 跑 corepack 自己解析出的那版(本机实测 11.21.0),
17
+ // 而 profile 的 node_modules 是**声明的那版**建的(`.modules.yaml` 写着 pnpm@11.7.0)
18
+ // ⇒ 版本错配 ⇒ Windows 上 rename 覆盖已存在目录 ⇒ ERR_PNPM_EPERM。
19
+ // 本机实据(两份都在,且都是 profile 声明的那版 11.7.0):
20
+ // · ~/.dsh/dsh-runtimes/dsh-primary-runtime/dependencies/pnpm
21
+ // · <桌面端安装目录>/resources/runtime/pnpm
22
+ //
23
+ // 全部探测只走注入的 `exists`(生产默认值 = 进程事实);本模块**不 spawn 任何东西**。
24
+ //
25
+ // 分层:L0 · infra(不得 import domain/routes/index —— tests/test-architecture-guard.mjs 断言②)。
26
+
27
+ import { dirname, join } from 'node:path'
28
+ import { homedir } from 'node:os'
29
+
30
+ /** PATH 条目里的 `<…>\runtime\pnpm\bin` 形态(桌面端 host 就是把它拼进 profile 的 PATH 的)。 */
31
+ const RUNTIME_PNPM_BIN_RE = /[\\/]runtime[\\/]pnpm[\\/]bin[\\/]?$/u
32
+ /** PATH 条目里的 `<…>\runtime\bin` 形态(其同级就是 `runtime\pnpm\bin\pnpm.mjs`)。 */
33
+ const RUNTIME_BIN_RE = /[\\/]runtime[\\/]bin[\\/]?$/u
34
+
35
+ /** 桌面端(Electron)自带运行时的 pnpm 入口候选(resourcesPath + PATH 两条线索)。 */
36
+ function desktopRuntimeCandidates({ resourcesPath, pathDirs, delimiter }) {
37
+ const out = []
38
+ if (typeof resourcesPath === 'string' && resourcesPath !== '') {
39
+ out.push(join(resourcesPath, 'runtime', 'pnpm', 'bin', 'pnpm.mjs'))
40
+ }
41
+ void delimiter
42
+ for (const dir of pathDirs) {
43
+ if (RUNTIME_PNPM_BIN_RE.test(dir)) out.push(join(dir, 'pnpm.mjs'))
44
+ else if (RUNTIME_BIN_RE.test(dir)) out.push(join(dirname(dir), 'pnpm', 'bin', 'pnpm.mjs'))
45
+ }
46
+ return out
47
+ }
48
+
49
+ /** DSH 运行时装那份 pnpm 的入口候选(**普通 node 进程也找得到**,0.5.48 新增)。
50
+ * 位置:`<dshHome>/dsh-runtimes/<runtime>/dependencies/pnpm/bin/pnpm.mjs`。
51
+ * `env.DSH_PNPM_HINT` 是显式注入点(单测用,也允许用户指定)。 */
52
+ function dshRuntimeCandidates({ env, home = homedir() }) {
53
+ const dshHomeDir = typeof env?.DSH_HOME === 'string' && env.DSH_HOME !== ''
54
+ ? env.DSH_HOME
55
+ : join(home, '.dsh')
56
+ const out = []
57
+ if (typeof env?.DSH_PNPM_HINT === 'string' && env.DSH_PNPM_HINT !== '') out.push(env.DSH_PNPM_HINT)
58
+ for (const rt of ['dsh-primary-runtime', 'dsh-runtime']) {
59
+ out.push(join(dshHomeDir, 'dsh-runtimes', rt, 'dependencies', 'pnpm', 'bin', 'pnpm.mjs'))
60
+ }
61
+ return out
62
+ }
63
+
64
+ export { RUNTIME_PNPM_BIN_RE, RUNTIME_BIN_RE, desktopRuntimeCandidates, dshRuntimeCandidates }
@@ -157,24 +157,78 @@ function openFolderBin() {
157
157
  return process.platform === 'darwin' ? 'open' : 'xdg-open'
158
158
  }
159
159
 
160
- // ── 唯一进程创建点(可注入 + 默认安全;2026-10-13 两次事故记录)──────────────────────────
160
+ // ── 唯一进程创建点(可注入 + 默认安全;2026-10-13 三次事故记录)──────────────────────────
161
161
  // 事故一(0.5.45):上一批测试为了覆盖"成功路径"直接调真路由 ⇒ 真的起了 explorer.exe,而夹具路径
162
162
  // 当时不存在 ⇒ 用户桌面弹出 Windows「位置不可用」对话框。
163
163
  // 事故二(0.5.46 施工中):又弹了一次 —— 根因是**默认不安全**:那时"默认真 spawn,只靠外部设
164
164
  // DSH_OPEN_FOLDER_NO_SPAWN=1 才拦得住",任何人/任何 CI 不带那个变量跑测试就会重新踩到。
165
- // ⇒ 现在把语义**反过来**:真 spawn 必须**显式 opt-in**,缺席时的默认结局是**被拒绝**(不是弹窗)。
166
- // 进程创建只此一处,四重防线:
165
+ // 事故三(2026-10-13,用户第三次截图):**又弹了一次**,指到夹具
166
+ // `tests\.testdir\open-folder-home\repos\probe-org\landed-re…`("位置不可用"= 启动时目标已不存在)。
167
+ // 根因不是"少设了一个变量",而是**两道门都只保护"不传 allowRealSpawn 的调用方"**:
168
+ // · opt-in 门:`routeRepoOpen` **永远**传 allowRealSpawn:true(它代表用户点了按钮)⇒ 测试里
169
+ // 调一次真路由 = 一次真 opt-in;
170
+ // · 注入门:测试里一句 `__setOpenFolderSpawn(null)`(原语义"还原生产实现")就把桩摘掉 ⇒
171
+ // 两个条件同时成立,真 explorer.exe 就在测试进程里起来了。
172
+ // 现在把语义收成**测试进程不可自解**的两层(不需要任何环境变量、任何调用方配合):
173
+ // ⓪ **测试进程判定**(isTestProcess):只看自己进程的入口脚本是不是本仓 `tests/**`(run-all 的子进程也算)
174
+ // ⇒ 是 ⇒ 模块**加载即装**测试桩(injectedSpawn = tripwire),且 `__setOpenFolderSpawn(null)`
175
+ // 还原到的仍然只能是这个测试桩 —— 安全网拆不掉;
167
176
  // ① 注入缝:单测 `__setOpenFolderSpawn(stub)` 换成桩(一次进程都不建);
168
177
  // ② 保险丝:`DSH_OPEN_FOLDER_NO_SPAWN=1` 时**绝不**走到 spawn —— 如实返回结构化拒绝
169
178
  // (不是静默跳过:调用方照旧把这条原因报给用户);
170
179
  // ③ **默认安全(opt-in)**:回落到真 `defaultSpawn` 需要 `allowRealSpawn:true` 或
171
180
  // `DSH_OPEN_FOLDER_ALLOW_REAL_SPAWN=1`;两者都没有 ⇒ 结构化拒绝。生产的 routeRepoOpen 是
172
181
  // 唯一的 opt-in 调用方 —— 于是"测试漏了注入"的默认结局是**红/被拒**,而不是弹用户一个窗口;
173
- // ④ 计数:真实 spawn 调用次数可读,测试断言恒为 0;`__openFolderSelfCheck()` 是计数器的正控
174
- // (真的起一次**不弹任何窗口**的进程,证明计数不是死的 —— 桩自校验,本仓既有做法)。
182
+ // ④ **运行期断言**:测试进程里"即将真 spawn"(无注入桩可选)⇒ **当场抛错**(见 openFolderVerified 顶部)
183
+ // —— 这一条专治"测试调真路由 ⇒ 真 opt-in"那条路(事故三);
184
+ // ⑤ 计数:真实 spawn 调用次数可读,测试断言恒为 0(自检那一次除外);`__openFolderSelfCheck()` 是
185
+ // 计数器的正控(真的起一次**不弹任何窗口**的进程,证明计数不是死的)。
175
186
  let injectedSpawn = null
176
187
  let realSpawnCalls = 0
177
188
 
189
+ /** **测试进程判定**(2026-10-13 第三次事故的根修;刻意**不依赖任何环境变量**)。
190
+ *
191
+ * 为什么必须有它:opt-in 那道门只保护"没传 allowRealSpawn 的调用方",而 **routeRepoOpen 永远传
192
+ * allowRealSpawn:true**(它代表"用户点了按钮")。于是"测试里调了一次真路由"= 一次真 opt-in
193
+ * ⇒ explorer.exe 照样弹窗;再叠上测试里一句 `__setOpenFolderSpawn(null)`("还原生产实现"),
194
+ * 安全网就被彻底拆掉。前两次事故(0.5.45 / 0.5.46 施工中)就是这么来的,第三次(2026-10-13
195
+ * 用户截图 `tests\.testdir\open-folder-home\repos\probe-org\landed-re…` 位置不可用)仍是这条路。
196
+ *
197
+ * 判据只看**自己进程的入口脚本**:`<repo>/tests/test-*.mjs`(含 run-all.mjs 的子进程)⇒ 测试进程。
198
+ * 生产入口(dsh / bin.js / 桌面端运行时)不匹配 ⇒ 行为与生产完全一致。绝不猜别的进程、绝不看环境变量。 */
199
+ function isTestProcess() {
200
+ // 子进程同样受保护:测试进程在加载本模块时会把标记写进环境(见文件下方"模块加载即装"),
201
+ // 于是 `node -e` / 临时脚本 / 任何由测试拉起的 node 子进程都被判成测试进程 —— 这一条**不是**
202
+ // "靠用户设环境变量才安全":标记是代码自己写、自己继承的,用户什么都不用做。
203
+ try {
204
+ if (process.env.DSH_TEST_PROCESS === '1') return true
205
+ } catch {}
206
+ try {
207
+ const entry = String(process.argv[1] ?? '').replace(/\\/gu, '/')
208
+ if (entry === '') return false
209
+ return /\/tests\/(?:test-[^/]*\.mjs|run-all\.mjs|probe-[^/]*\.cjs)$/u.test(entry)
210
+ } catch {
211
+ return false
212
+ }
213
+ }
214
+
215
+ /** 测试进程里的默认桩:**在创建进程之前**抛错 —— 这就是"即将真 spawn ⇒ 当场红"的运行期断言
216
+ * (不是放行、也不是结构化拒绝:漏注入的结局必须是红)。 */
217
+ function testProcessTripwire() {
218
+ throw new Error('测试红线:测试进程里绝不允许真的启动文件管理器(explorer.exe)—— 请在测试里注入 spawnFn / __setOpenFolderSpawn(stub),或用 rc.deps.openFolderLauncher')
219
+ }
220
+
221
+ // ★ 模块加载即装(无需任何 env、无需任何调用方配合):测试进程里 `injectedSpawn` 永远是一个桩,
222
+ // 连生产的 opt-in 调用方(routeRepoOpen)也绕不过它 —— 测试**永远**不 opt-in。
223
+ // 同时把"我是测试进程"写进环境,让子进程(node -e / 临时脚本 / 任何 node 子进程)继承同一道保护:
224
+ // 光看 argv[1] 只能认出"入口就是测试文件"的那一层,而测试拉起的子进程完全可能调真路由(事故三的形状)。
225
+ if (isTestProcess()) {
226
+ injectedSpawn = testProcessTripwire
227
+ try {
228
+ process.env.DSH_TEST_PROCESS = '1'
229
+ } catch {}
230
+ }
231
+
178
232
  /** 真 spawn 的**显式 opt-in**(默认拒绝;见 openFolderVerified 第 ③ 条)。 */
179
233
  function realSpawnOptIn(allowRealSpawn) {
180
234
  return allowRealSpawn === true || process.env.DSH_OPEN_FOLDER_ALLOW_REAL_SPAWN === '1'
@@ -185,9 +239,12 @@ function defaultSpawn(bin, args, options) {
185
239
  return spawn(bin, args, options)
186
240
  }
187
241
 
188
- /** 单测注入启动桩:传函数 = 打桩(**不创建任何进程**);传 null = 还原生产实现。 */
242
+ /** 单测注入启动桩:传函数 = 打桩(**不创建任何进程**);传 null = 还原生产实现 ——
243
+ * **但在测试进程里"还原"到的只能还是测试桩**(见 isTestProcess 顶部注释:一句
244
+ * `__setOpenFolderSpawn(null)` 就把真实 explorer 重新武装起来,正是第三次弹窗的根因)。
245
+ * 返回 `injectedSpawn !== null`(测试进程里恒为 true:安全网不可被测试自己拆掉)。 */
189
246
  function __setOpenFolderSpawn(fn) {
190
- injectedSpawn = typeof fn === 'function' ? fn : null
247
+ injectedSpawn = typeof fn === 'function' ? fn : (isTestProcess() ? testProcessTripwire : null)
191
248
  return injectedSpawn !== null
192
249
  }
193
250
 
@@ -196,6 +253,19 @@ function __openFolderRealSpawnCalls() {
196
253
  return realSpawnCalls
197
254
  }
198
255
 
256
+ /** 安全网现状(只读;测试用来断言"测试进程里桩摘不掉"这件事本身)。 */
257
+ function __openFolderSafetyState() {
258
+ return { testProcess: isTestProcess(), injected: injectedSpawn !== null, realSpawnCalls }
259
+ }
260
+
261
+ /** **仅测试用**:故意把注入桩摘掉,用来证明"即将真 spawn ⇒ 当场抛错给红"这条运行期断言是活的
262
+ * (不是一句没人走的空话)。正常路径下测试进程**永远**摘不掉桩 —— 本函数只服务变异验证/负控,
263
+ * 生产代码不得调用(它在测试进程里只会让下一次真实启动请求当场抛错)。 */
264
+ function __forceUnsafeSpawnForTest() {
265
+ injectedSpawn = null
266
+ return injectedSpawn === null
267
+ }
268
+
199
269
  /** 计数器正控(桩自校验):用**生产同一个** defaultSpawn 起一次 node -e ""(无窗口、无副作用),
200
270
  * 返回它是否被计数到。返回 false ⇒ 计数桩坏了,测试必须据此响亮失败,而不是拿 0 当"没启动过"。 */
201
271
  async function __openFolderSelfCheck() {
@@ -237,6 +307,13 @@ function openFolderPathIssue(target) {
237
307
  * 判据(本仓口径):宁可红也不报假绿;静默一律不许。 */
238
308
  function openFolderVerified({ target, bin = null, spawnFn = null, launchWaitMs = 1200, allowRealSpawn = false } = {}) {
239
309
  const started = Date.now()
310
+ // ★ 运行期断言(2026-10-13 第三次事故的根修):**测试进程里"即将真 spawn"一律当场抛错**。
311
+ // 判定发生在任何进程创建之前、且**先于** opt-in:生产的 routeRepoOpen 会传 allowRealSpawn:true,
312
+ // 测试里调真路由 = 真 opt-in ⇒ 只有这一道断言能拦住它("测试永远不 opt-in"就是这么落实的)。
313
+ const injectedNow = typeof spawnFn === 'function' ? spawnFn : injectedSpawn
314
+ if (injectedNow === null && isTestProcess()) {
315
+ throw new Error('测试红线:测试进程里即将真的启动文件管理器(explorer.exe)—— 已当场拒绝并给红(请注入 spawnFn / __setOpenFolderSpawn(stub) / rc.deps.openFolderLauncher)')
316
+ }
240
317
  return new Promise((resolve) => {
241
318
  // ② 保险丝:测试红线。**先于任何进程创建**判定,且如实说明被拦下的原因。
242
319
  if (process.env.DSH_OPEN_FOLDER_NO_SPAWN === '1') {
@@ -433,4 +510,4 @@ async function routeComponentStatus(req, res, rc) {
433
510
  return
434
511
  }
435
512
 
436
- export { routeComponents, routeRepoClone, routeRepoList, routeRepoLandConfig, routeRepoRemove, routeRepoOpen, routeComponentAutostart, routeComponentStart, routeComponentStop, routeComponentStatus, openFolderBin, openFolderVerified, __setOpenFolderSpawn, __openFolderRealSpawnCalls, __openFolderSelfCheck }
513
+ export { routeComponents, routeRepoClone, routeRepoList, routeRepoLandConfig, routeRepoRemove, routeRepoOpen, routeComponentAutostart, routeComponentStart, routeComponentStop, routeComponentStatus, openFolderBin, openFolderVerified, isTestProcess, __setOpenFolderSpawn, __openFolderRealSpawnCalls, __openFolderSelfCheck, __openFolderSafetyState, __forceUnsafeSpawnForTest }
@@ -1,12 +1,16 @@
1
1
  // L2 · routes —— 安装(POST /install · POST /install-status)
2
2
  // 分层 Step 8b:从 lib/index.js 的 handle() 原样搬出(只搬移未改逻辑;缩进保持原样)
3
3
 
4
+ import { dirname } from 'node:path'
4
5
  import { installJobView } from '../domain/install.js'
5
6
  import { runInstallJob } from '../domain/install-job.js'
7
+ import { failedJobEvidence } from '../domain/install-verify.js'
6
8
  import { fetchRepoPackageEx, githubRepoInfo, subpackageCandidates } from '../domain/market.js'
7
9
  import { runSkillInstallJob } from '../domain/skills.js'
10
+ import { normalizeSubdir } from '../domain/subdir-install.js'
8
11
  import { probeGitmodules, resolveInstallKind, runSuiteInstallJob } from '../domain/suite.js'
9
12
  import { sendError, sendJson } from '../infra/httpd.js'
13
+ import { findPatchPath } from '../infra/paths.js'
10
14
  import { installJobs, nextInstallJobSeq } from '../state.js'
11
15
 
12
16
  async function routeInstall(req, res, rc) {
@@ -38,6 +42,20 @@ async function routeInstall(req, res, rc) {
38
42
  return
39
43
  }
40
44
  const source = body.source === 'gitee' ? body.source : 'github'
45
+ // 子目录插件的一键安装(2026-10-13 加法,第二轮):面板在「插件在 <子目录>/ 下」那张卡片上
46
+ // 直接带 `subdir` 过来。**格式先在这里拦住**(与 packageName 同一套"来源校验一视同仁"的写法),
47
+ // 判据的权威仍是 domain/subdir-install.js#normalizeSubdir —— 两边同一把尺子。
48
+ const rawSubdir = typeof body.subdir === 'string' ? body.subdir.trim() : ''
49
+ const subdir = rawSubdir === '' ? null : normalizeSubdir(rawSubdir)
50
+ if (rawSubdir !== '' && subdir === null) {
51
+ sendError(res, 400, 'subdir 不合法:只接受仓库里第 1~2 层的相对目录(如 ds-bot 或 packages/foo),不接受绝对路径、盘符、反斜杠与 . / ..')
52
+ return
53
+ }
54
+ if (subdir !== null && repo === '') {
55
+ sendError(res, 400, '从子目录安装必须提供仓库(owner/name)')
56
+ return
57
+ }
58
+ const branch = typeof body.branch === 'string' && body.branch.trim() !== '' ? body.branch.trim() : null
41
59
  const npmNamePattern = /^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/u
42
60
  if (givenName !== '' && (!npmNamePattern.test(givenName) || givenName.length > 214)) {
43
61
  sendError(res, 400, 'packageName 不是合法的 npm 包名')
@@ -77,6 +95,9 @@ async function routeInstall(req, res, rc) {
77
95
  lastError: null,
78
96
  update: body.update === true,
79
97
  kind,
98
+ // 子目录插件安装(纯加法:不带 subdir 时这两项就是 null,作业形状与改动前逐字相同)
99
+ subdir,
100
+ branch,
80
101
  }
81
102
  if (suiteNote !== null) job.suiteNote = suiteNote
82
103
  installJobs.set(job.id, job)
@@ -117,6 +138,12 @@ async function routeInstallStatus(req, res, rc) {
117
138
  sendJson(res, 200, {
118
139
  ok: true,
119
140
  ...installJobView(job),
141
+ // ★「其实已经装上了」核实(0.5.47 改错,用户被误导过):**报失败/给重试之前**先查三处
142
+ // (依赖清单 / dsh.profile.bundles / node_modules)——已装 ⇒ 面板显示「已安装 vX(无需重装)」,
143
+ // 历史失败降级为次要信息。作业自己的那份(例如子目录安装早退时算的)在没有失败时照旧下发。
144
+ installedEvidence: job.status === 'failed'
145
+ ? (failedJobEvidence(dirname(findPatchPath(ctx)), job) ?? job.installedEvidence ?? null)
146
+ : (job.installedEvidence ?? null),
120
147
  // 结构化建议动作(2026-09-27 加法,向后兼容):老客户端忽略该字段;
121
148
  // 新客户端在安装结果卡片里渲染「执行 / 复制」小框(POST /plugin-console/run-suggested)。
122
149
  // 放这里而不是 install.js#installJobView:那个模块已贴着 600 行架构棘轮(见 test-architecture-guard.mjs)。
@@ -8,6 +8,7 @@ import { compUiUrl, findComponents } from '../domain/components.js'
8
8
  import { currentFrameworkVersion, detectFrameworkUpgrade } from '../domain/framework.js'
9
9
  import { peerVetoFor } from '../domain/peer-veto.js'
10
10
  import { readExtraBundleRows } from '../domain/install-job.js'
11
+ import { failedJobEvidence } from '../domain/install-verify.js'
11
12
  import { installJobView, readGithubAuth } from '../domain/install.js'
12
13
  import { readPluginDetails } from '../domain/market.js'
13
14
  import { healPatchSafety, readPatchState } from '../domain/patch.js'
@@ -103,7 +104,12 @@ async function routeStateGet(req, res, rc) {
103
104
  const compat = await detectCompat(ctx.baseUrl ?? 'file:///')
104
105
  const auth = readGithubAuth()
105
106
  const jobs = [...installJobs.values()].filter((job) => job.status === 'installing').map(installJobView)
106
- const recentFailures = [...installJobs.values()].filter((job) => job.status === 'failed').slice(-3).map(installJobView)
107
+ // 「最近安装失败」这张卡(0.5.47 改错,用户被误导过):**下发之前**先查三处(依赖清单 /
108
+ // dsh.profile.bundles / node_modules)—— 那个插件其实已经装上时,面板必须先说
109
+ // 「已安装 vX(无需重装)」,把历史失败降级成次要信息,而不是让用户以为没装上、再点一次重试。
110
+ // 核实是**只读**的(读 profile 清单 + 盘上包),每次都按**当前磁盘**现查(历史失败也要能翻案)。
111
+ const recentFailures = [...installJobs.values()].filter((job) => job.status === 'failed').slice(-3)
112
+ .map((job) => ({ ...installJobView(job), installedEvidence: failedJobEvidence(profileDir, job) ?? job.installedEvidence ?? null }))
107
113
  // 已安装但尚未生效的任务(2026-09-20 真装真卸演练实测):bundle 型插件要重启才被加载,
108
114
  // 装完 /install 返回 entryId: null、/state 里新增 loader 条目 = 0 —— 面板过去完全看不出
109
115
  // 「装了但还没生效」,也无法撤销。前端据此渲染「已安装·重启后生效」徽标 + 按 jobId 删除。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noob-stupid/dsh-plugin-console",
3
- "version": "0.5.46",
3
+ "version": "0.5.48",
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",