@noob-stupid/dsh-plugin-console 0.5.45 → 0.5.47
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.
- package/lib/client.js +145 -15
- package/lib/server/domain/bundle-refs.js +42 -2
- package/lib/server/domain/install-job.js +53 -52
- package/lib/server/domain/install-verify.js +59 -1
- package/lib/server/domain/install.js +8 -0
- package/lib/server/domain/landed-source.js +79 -0
- package/lib/server/domain/manifest-probe.js +68 -0
- package/lib/server/domain/market.js +117 -24
- package/lib/server/domain/presence-check.js +121 -0
- package/lib/server/domain/probe-outcome.js +58 -0
- package/lib/server/domain/registry-manifest.js +102 -0
- package/lib/server/domain/repo-package-match.js +135 -0
- package/lib/server/domain/repo-url.js +23 -0
- package/lib/server/domain/subdir-detect.js +181 -0
- package/lib/server/domain/subdir-install.js +368 -0
- package/lib/server/domain/suite.js +4 -1
- package/lib/server/infra/egress.js +208 -0
- package/lib/server/infra/http.js +263 -119
- package/lib/server/routes/components.js +262 -10
- package/lib/server/routes/install.js +27 -0
- package/lib/server/routes/state.js +7 -1
- package/package.json +1 -1
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// L2 · routes —— 组件与仓库落地(/components · /component/* · /repo-*)
|
|
2
2
|
// 分层 Step 8b:从 lib/index.js 的 handle() 原样搬出(只搬移未改逻辑;缩进保持原样)
|
|
3
3
|
|
|
4
|
-
import { existsSync, mkdirSync } from 'node:fs'
|
|
5
|
-
import {
|
|
4
|
+
import { existsSync, mkdirSync, statSync } from 'node:fs'
|
|
5
|
+
import { spawn } from 'node:child_process'
|
|
6
6
|
import { join } from 'node:path'
|
|
7
7
|
import { compFind, compStart, compStatus, compStop, compUiUrl, compUpsert, findComponents } from '../domain/components.js'
|
|
8
8
|
import { getReposDir, listLandedRepos, setReposDir } from '../domain/repoland.js'
|
|
@@ -151,6 +151,236 @@ async function routeRepoRemove(req, res, rc) {
|
|
|
151
151
|
return
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
+
/** 「打开文件夹」用的文件管理器(Windows=explorer.exe;macOS=open;其余=xdg-open)。 */
|
|
155
|
+
function openFolderBin() {
|
|
156
|
+
if (process.platform === 'win32') return 'explorer.exe'
|
|
157
|
+
return process.platform === 'darwin' ? 'open' : 'xdg-open'
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ── 唯一进程创建点(可注入 + 默认安全;2026-10-13 三次事故记录)──────────────────────────
|
|
161
|
+
// 事故一(0.5.45):上一批测试为了覆盖"成功路径"直接调真路由 ⇒ 真的起了 explorer.exe,而夹具路径
|
|
162
|
+
// 当时不存在 ⇒ 用户桌面弹出 Windows「位置不可用」对话框。
|
|
163
|
+
// 事故二(0.5.46 施工中):又弹了一次 —— 根因是**默认不安全**:那时"默认真 spawn,只靠外部设
|
|
164
|
+
// DSH_OPEN_FOLDER_NO_SPAWN=1 才拦得住",任何人/任何 CI 不带那个变量跑测试就会重新踩到。
|
|
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
|
+
// 还原到的仍然只能是这个测试桩 —— 安全网拆不掉;
|
|
176
|
+
// ① 注入缝:单测 `__setOpenFolderSpawn(stub)` 换成桩(一次进程都不建);
|
|
177
|
+
// ② 保险丝:`DSH_OPEN_FOLDER_NO_SPAWN=1` 时**绝不**走到 spawn —— 如实返回结构化拒绝
|
|
178
|
+
// (不是静默跳过:调用方照旧把这条原因报给用户);
|
|
179
|
+
// ③ **默认安全(opt-in)**:回落到真 `defaultSpawn` 需要 `allowRealSpawn:true` 或
|
|
180
|
+
// `DSH_OPEN_FOLDER_ALLOW_REAL_SPAWN=1`;两者都没有 ⇒ 结构化拒绝。生产的 routeRepoOpen 是
|
|
181
|
+
// 唯一的 opt-in 调用方 —— 于是"测试漏了注入"的默认结局是**红/被拒**,而不是弹用户一个窗口;
|
|
182
|
+
// ④ **运行期断言**:测试进程里"即将真 spawn"(无注入桩可选)⇒ **当场抛错**(见 openFolderVerified 顶部)
|
|
183
|
+
// —— 这一条专治"测试调真路由 ⇒ 真 opt-in"那条路(事故三);
|
|
184
|
+
// ⑤ 计数:真实 spawn 调用次数可读,测试断言恒为 0(自检那一次除外);`__openFolderSelfCheck()` 是
|
|
185
|
+
// 计数器的正控(真的起一次**不弹任何窗口**的进程,证明计数不是死的)。
|
|
186
|
+
let injectedSpawn = null
|
|
187
|
+
let realSpawnCalls = 0
|
|
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
|
+
|
|
232
|
+
/** 真 spawn 的**显式 opt-in**(默认拒绝;见 openFolderVerified 第 ③ 条)。 */
|
|
233
|
+
function realSpawnOptIn(allowRealSpawn) {
|
|
234
|
+
return allowRealSpawn === true || process.env.DSH_OPEN_FOLDER_ALLOW_REAL_SPAWN === '1'
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function defaultSpawn(bin, args, options) {
|
|
238
|
+
realSpawnCalls += 1
|
|
239
|
+
return spawn(bin, args, options)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** 单测注入启动桩:传函数 = 打桩(**不创建任何进程**);传 null = 还原生产实现 ——
|
|
243
|
+
* **但在测试进程里"还原"到的只能还是测试桩**(见 isTestProcess 顶部注释:一句
|
|
244
|
+
* `__setOpenFolderSpawn(null)` 就把真实 explorer 重新武装起来,正是第三次弹窗的根因)。
|
|
245
|
+
* 返回 `injectedSpawn !== null`(测试进程里恒为 true:安全网不可被测试自己拆掉)。 */
|
|
246
|
+
function __setOpenFolderSpawn(fn) {
|
|
247
|
+
injectedSpawn = typeof fn === 'function' ? fn : (isTestProcess() ? testProcessTripwire : null)
|
|
248
|
+
return injectedSpawn !== null
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** 真实子进程创建次数(测试断言本套跑完仍为 0)。 */
|
|
252
|
+
function __openFolderRealSpawnCalls() {
|
|
253
|
+
return realSpawnCalls
|
|
254
|
+
}
|
|
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
|
+
|
|
269
|
+
/** 计数器正控(桩自校验):用**生产同一个** defaultSpawn 起一次 node -e ""(无窗口、无副作用),
|
|
270
|
+
* 返回它是否被计数到。返回 false ⇒ 计数桩坏了,测试必须据此响亮失败,而不是拿 0 当"没启动过"。 */
|
|
271
|
+
async function __openFolderSelfCheck() {
|
|
272
|
+
const before = realSpawnCalls
|
|
273
|
+
try {
|
|
274
|
+
const child = defaultSpawn(process.execPath, ['-e', ''], { windowsHide: true, stdio: 'ignore' })
|
|
275
|
+
child.on('error', () => {})
|
|
276
|
+
await new Promise((resolve) => { child.on('exit', resolve); setTimeout(resolve, 2000) })
|
|
277
|
+
} catch {
|
|
278
|
+
return { ok: false, counted: realSpawnCalls - before }
|
|
279
|
+
}
|
|
280
|
+
return { ok: realSpawnCalls - before === 1, counted: realSpawnCalls - before }
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** 路径能否"打开文件夹":存在且是目录(文件也给开,交给文件管理器处理)。
|
|
284
|
+
* 返回 null 表示可以打开,否则是给用户看的原因(不抛)。 */
|
|
285
|
+
function openFolderPathIssue(target) {
|
|
286
|
+
try {
|
|
287
|
+
const st = statSync(target)
|
|
288
|
+
return st.isDirectory() || st.isFile() ? null : `${target} 既不是文件也不是目录`
|
|
289
|
+
} catch (error) {
|
|
290
|
+
const code = error?.code ?? ''
|
|
291
|
+
if (code === 'ENOENT') return `目录不存在:${target}(可能已被删除或改名,请刷新落地列表)`
|
|
292
|
+
return `读不到该路径:${target}(${error instanceof Error ? error.message : String(error)})`
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** 打开文件夹的**可判定**执行器(2026-10-13 改错,用户实测「点了没反应」):
|
|
297
|
+
* 旧实现是 `execFile('explorer.exe', [target], {detached:true}).unref()` —— **即发即忘**:
|
|
298
|
+
* ① 没有 error 监听(spawn 失败如 ENOENT 是**异步** 'error' 事件,try/catch 抓不到,
|
|
299
|
+
* 实测:`spawn('不存在的.exe')` 在 try 块内不抛,一个 tick 后才在 error 事件里出现);
|
|
300
|
+
* ② 立刻回 `{ok:true}` —— 失败也报成功(假绿),用户拿不到任何判据;
|
|
301
|
+
* ③ 客户端又把非 2xx 统一吞掉(`catch {}`)⇒ 用户那边**一个字都看不到**。
|
|
302
|
+
* 现在:启动后**等它的结局**再回话(对象只描述观察到的事实):
|
|
303
|
+
* · 'error'(ENOENT/EACCES…)→ ok:false + 系统原话(这就是"失败必须说出来"的正身);
|
|
304
|
+
* · 'exit' → ok:true,退出码与用时**如实带上**(真机 explorer.exe 把请求交给已有 shell 进程后
|
|
305
|
+
* 自己退出,退出码常是 1;这是委派而非失败,所以不判红,但也绝不谎称"窗口已打开");
|
|
306
|
+
* · 超时仍在跑 → ok:true 且注明"未在 Nms 内退出"。
|
|
307
|
+
* 判据(本仓口径):宁可红也不报假绿;静默一律不许。 */
|
|
308
|
+
function openFolderVerified({ target, bin = null, spawnFn = null, launchWaitMs = 1200, allowRealSpawn = false } = {}) {
|
|
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
|
+
}
|
|
317
|
+
return new Promise((resolve) => {
|
|
318
|
+
// ② 保险丝:测试红线。**先于任何进程创建**判定,且如实说明被拦下的原因。
|
|
319
|
+
if (process.env.DSH_OPEN_FOLDER_NO_SPAWN === '1') {
|
|
320
|
+
resolve({
|
|
321
|
+
ok: false,
|
|
322
|
+
reason: 'spawn-guard',
|
|
323
|
+
durationMs: Date.now() - started,
|
|
324
|
+
error: '测试红线:本进程禁止真的启动文件管理器(DSH_OPEN_FOLDER_NO_SPAWN=1)',
|
|
325
|
+
})
|
|
326
|
+
return
|
|
327
|
+
}
|
|
328
|
+
// ③ **默认安全**(2026-10-13 第二次事故后收紧):回落到**底层真 spawn**(defaultSpawn)需要
|
|
329
|
+
// **显式 opt-in**;注入桩(spawnFn / __setOpenFolderSpawn)不在此列(调用方自担,且不建进程)。
|
|
330
|
+
// 为什么必须这样:旧形态是"默认真 spawn,靠外部设 DSH_OPEN_FOLDER_NO_SPAWN 才拦住"——
|
|
331
|
+
// 任何一条测试路径漏了注入(或忘了设那个环境变量)就会真的起 explorer.exe 指到夹具目录,
|
|
332
|
+
// 在用户桌面弹出「位置不可用」(真机第二次事故)。现在"漏了注入"的默认结局是**被拒绝**,
|
|
333
|
+
// 不是弹窗。生产的 routeRepoOpen 是本函数**唯一**的 opt-in 调用方(它代表"用户点了按钮")。
|
|
334
|
+
const injected = typeof spawnFn === 'function' ? spawnFn : injectedSpawn
|
|
335
|
+
if (injected === null && !realSpawnOptIn(allowRealSpawn)) {
|
|
336
|
+
resolve({
|
|
337
|
+
ok: false,
|
|
338
|
+
reason: 'spawn-guard',
|
|
339
|
+
durationMs: Date.now() - started,
|
|
340
|
+
error: '测试红线:本进程默认禁止真的启动文件管理器 —— 真 spawn 需显式 opt-in(openFolderVerified({allowRealSpawn:true}) 或 DSH_OPEN_FOLDER_ALLOW_REAL_SPAWN=1)',
|
|
341
|
+
})
|
|
342
|
+
return
|
|
343
|
+
}
|
|
344
|
+
const launch = injected ?? defaultSpawn
|
|
345
|
+
const command = typeof bin === 'string' && bin !== '' ? bin : openFolderBin()
|
|
346
|
+
let child = null
|
|
347
|
+
try {
|
|
348
|
+
// stdio:'ignore' —— 不建管道(旧 execFile 默认管道 + unref 会留下未 unref 的 pipe 句柄),
|
|
349
|
+
// detached:true 让文件管理器窗口在插件宿主退出后依然在。
|
|
350
|
+
child = launch(command, [target], { windowsHide: true, detached: true, stdio: 'ignore' })
|
|
351
|
+
} catch (error) {
|
|
352
|
+
resolve({ ok: false, reason: 'spawn-throw', durationMs: Date.now() - started, error: error instanceof Error ? error.message : String(error) })
|
|
353
|
+
return
|
|
354
|
+
}
|
|
355
|
+
let done = false
|
|
356
|
+
const finish = (payload) => {
|
|
357
|
+
if (done) return
|
|
358
|
+
done = true
|
|
359
|
+
clearTimeout(timer)
|
|
360
|
+
resolve({ durationMs: Date.now() - started, ...payload })
|
|
361
|
+
}
|
|
362
|
+
const timer = setTimeout(() => finish({
|
|
363
|
+
ok: true,
|
|
364
|
+
reason: 'launched',
|
|
365
|
+
exited: false,
|
|
366
|
+
note: `已向 ${command} 发出打开请求,进程在 ${launchWaitMs}ms 内未退出(窗口通常已弹出)`,
|
|
367
|
+
}), launchWaitMs)
|
|
368
|
+
child.on('error', (error) => finish({
|
|
369
|
+
ok: false,
|
|
370
|
+
reason: 'spawn-error',
|
|
371
|
+
code: error?.code ?? null,
|
|
372
|
+
error: error instanceof Error ? error.message : String(error),
|
|
373
|
+
}))
|
|
374
|
+
child.on('exit', (code, signal) => finish({
|
|
375
|
+
ok: true,
|
|
376
|
+
reason: 'exited',
|
|
377
|
+
code,
|
|
378
|
+
note: `${command} 进程已退出(exit ${code ?? '?'}${signal ? `/${signal}` : ''},用时 ${Date.now() - started}ms)—— 请求已交给系统文件管理器`,
|
|
379
|
+
}))
|
|
380
|
+
try { child.unref?.() } catch {}
|
|
381
|
+
})
|
|
382
|
+
}
|
|
383
|
+
|
|
154
384
|
async function routeRepoOpen(req, res, rc) {
|
|
155
385
|
const ctx = rc.ctx
|
|
156
386
|
const url = rc.url
|
|
@@ -161,19 +391,41 @@ async function routeRepoOpen(req, res, rc) {
|
|
|
161
391
|
const rootNorm = getReposDir().replace(/[\\/]+$/u, '')
|
|
162
392
|
const tNorm = target.replace(/[\\/]+$/u, '')
|
|
163
393
|
if (!tNorm.startsWith(rootNorm) || tNorm === rootNorm) {
|
|
164
|
-
|
|
394
|
+
// 守卫照旧(只允许落地目录内的路径),但**必须说清为什么被拒** ——
|
|
395
|
+
// 旧实现只回一句 400,客户端又把它吞了 ⇒ 用户看到的就是"点了没反应"。
|
|
396
|
+
sendError(res, 400, `为安全起见只能打开仓库落地目录内的路径:${target || '(空路径)'} 不在 ${getReposDir()} 内`)
|
|
165
397
|
return
|
|
166
398
|
}
|
|
167
399
|
if (!existsSync(target)) {
|
|
168
|
-
sendError(res, 404, `目录不存在:${target}
|
|
400
|
+
sendError(res, 404, `目录不存在:${target}(可能已被删除或改名,请刷新落地列表)`)
|
|
169
401
|
return
|
|
170
402
|
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
403
|
+
const issue = openFolderPathIssue(target)
|
|
404
|
+
if (issue !== null) {
|
|
405
|
+
sendError(res, 400, `无法打开:${issue}`)
|
|
406
|
+
return
|
|
407
|
+
}
|
|
408
|
+
// 启动点是注入缝(单测经 rc.deps.openFolderLauncher 打桩,**绝不真的起文件管理器**);
|
|
409
|
+
// 生产 routeDeps 不带这个键 ⇒ 走 openFolderVerified 的实现。
|
|
410
|
+
const launch = typeof rc.deps?.openFolderLauncher === 'function' ? rc.deps.openFolderLauncher : openFolderVerified
|
|
411
|
+
// 生产唯一的 opt-in 调用方:用户点了「打开文件夹」按钮,才允许回落到真 spawn。
|
|
412
|
+
const result = await launch({ target, bin: openFolderBin(), allowRealSpawn: true })
|
|
413
|
+
if (result.ok !== true) {
|
|
414
|
+
sendError(res, 500, `打开文件夹失败:${result.error ?? result.reason}(命令:${openFolderBin()} ${target})`)
|
|
415
|
+
return
|
|
176
416
|
}
|
|
417
|
+
// 如实回结构化状态:成功也带证据(用时/退出码/note),前端据此显示结果而不是静默。
|
|
418
|
+
sendJson(res, 200, {
|
|
419
|
+
ok: true,
|
|
420
|
+
path: target,
|
|
421
|
+
target,
|
|
422
|
+
action: 'open-folder',
|
|
423
|
+
via: openFolderBin(),
|
|
424
|
+
reason: result.reason,
|
|
425
|
+
durationMs: result.durationMs ?? null,
|
|
426
|
+
exitCode: typeof result.code === 'number' ? result.code : null,
|
|
427
|
+
note: typeof result.note === 'string' ? result.note : '',
|
|
428
|
+
})
|
|
177
429
|
return
|
|
178
430
|
}
|
|
179
431
|
|
|
@@ -258,4 +510,4 @@ async function routeComponentStatus(req, res, rc) {
|
|
|
258
510
|
return
|
|
259
511
|
}
|
|
260
512
|
|
|
261
|
-
export { routeComponents, routeRepoClone, routeRepoList, routeRepoLandConfig, routeRepoRemove, routeRepoOpen, routeComponentAutostart, routeComponentStart, routeComponentStop, routeComponentStatus }
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.5.47",
|
|
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",
|