@noob-stupid/dsh-plugin-console 0.5.44 → 0.5.46
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 +53 -7
- package/lib/server/domain/bundle-refs.js +42 -2
- package/lib/server/domain/install-job.js +29 -42
- package/lib/server/domain/install.js +4 -0
- package/lib/server/domain/market.js +54 -5
- package/lib/server/domain/subdir-detect.js +152 -0
- package/lib/server/routes/components.js +185 -10
- package/lib/server/routes/framework.js +47 -5
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -458,6 +458,11 @@ window.__ModuleLoader__.load({
|
|
|
458
458
|
repoLandBusy: "克隆中…(大仓库可能较慢)",
|
|
459
459
|
repoLandDone: "已落地到:",
|
|
460
460
|
repoLandOpenFolder: "打开文件夹",
|
|
461
|
+
repoOpenDone: "已请求打开:",
|
|
462
|
+
repoOpenFailed: "打开文件夹失败:",
|
|
463
|
+
subpluginFound: "该仓库根目录没有清单,但插件在子目录 {dir}/ 下({pkg},标记:{markers})—— 它不是「不是插件」,只是清单不在仓库根",
|
|
464
|
+
subpluginCopyDir: "复制子目录路径",
|
|
465
|
+
subpluginCopyName: "复制包名",
|
|
461
466
|
repoLandExists: "该仓库已存在,无需重复落地",
|
|
462
467
|
repoLandDirLabel: "保存路径",
|
|
463
468
|
repoLandSaveDir: "保存路径",
|
|
@@ -1018,6 +1023,11 @@ window.__ModuleLoader__.load({
|
|
|
1018
1023
|
repoLandBusy: "Cloning… (large repos may take a while)",
|
|
1019
1024
|
repoLandDone: "Landed at: ",
|
|
1020
1025
|
repoLandOpenFolder: "Open folder",
|
|
1026
|
+
repoOpenDone: "Requested to open: ",
|
|
1027
|
+
repoOpenFailed: "Failed to open folder: ",
|
|
1028
|
+
subpluginFound: "This repository has no manifest at its root, but the plugin lives in the subdirectory {dir}/ ({pkg}, markers: {markers}) — it is not \"not a plugin\", the manifest is just not at the repository root",
|
|
1029
|
+
subpluginCopyDir: "Copy subdirectory path",
|
|
1030
|
+
subpluginCopyName: "Copy package name",
|
|
1021
1031
|
repoLandExists: "Already exists — nothing to clone",
|
|
1022
1032
|
repoLandDirLabel: "Save path",
|
|
1023
1033
|
repoLandSaveDir: "Save path",
|
|
@@ -2229,7 +2239,10 @@ window.__ModuleLoader__.load({
|
|
|
2229
2239
|
if (!window.confirm(t("repoLandRemoveConfirm").replace("{repo}", item.repo))) return;
|
|
2230
2240
|
try {
|
|
2231
2241
|
await call("/plugin-console/repo-remove", { path: item.path });
|
|
2232
|
-
} catch {
|
|
2242
|
+
} catch (error) {
|
|
2243
|
+
// 2026-10-13 改错:删不掉时旧代码 `catch {}` 静默吞掉,用户看到的是"点了没反应、仓库还在"。
|
|
2244
|
+
setMessage(t("safetySwitchFailed") + ":" + (error && typeof error.message === "string" && error.message !== "" ? error.message : t("safetySwitchFailed")));
|
|
2245
|
+
}
|
|
2233
2246
|
repoLandRefresh();
|
|
2234
2247
|
}
|
|
2235
2248
|
async function repoLandStart() {
|
|
@@ -2245,9 +2258,22 @@ window.__ModuleLoader__.load({
|
|
|
2245
2258
|
}
|
|
2246
2259
|
setRepoLandBusy(false);
|
|
2247
2260
|
}
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2261
|
+
/** 打开仓库落地目录(2026-10-13 改错:**成功与失败都必须有结果**)。
|
|
2262
|
+
* 旧实现 `try { await call(...) } catch {}` —— 非 2xx(路径被守卫拒绝 / 目录不存在 /
|
|
2263
|
+
* 文件管理器起不来)全被吞掉,用户只看到"点了没反应"(真机实测报告)。
|
|
2264
|
+
* 现在:服务端回结构化状态 → 成功显示"已在文件管理器中打开:<path>"(服务端的 note 也带上),
|
|
2265
|
+
* 失败把**原因原样**显示(守卫拒绝的原因、目录不存在、spawn 失败的系统原话)。
|
|
2266
|
+
* 供「刚落地」按钮与「已落地列表」每行的按钮共用(两处行为必须一致,别再各写一遍)。 */
|
|
2267
|
+
async function repoLandOpenFolder(target) {
|
|
2268
|
+
const path = typeof target === "string" && target !== "" ? target : (repoLandResult && repoLandResult.path);
|
|
2269
|
+
if (!path) return;
|
|
2270
|
+
try {
|
|
2271
|
+
const data = await call("/plugin-console/repo-open", { path });
|
|
2272
|
+
const note = data && typeof data.note === "string" && data.note !== "" ? "(" + data.note + ")" : "";
|
|
2273
|
+
setMessage(t("repoOpenDone") + (data && data.path ? data.path : path) + note);
|
|
2274
|
+
} catch (error) {
|
|
2275
|
+
setMessage(t("repoOpenFailed") + (error && typeof error.message === "string" && error.message !== "" ? error.message : t("safetySwitchFailed")));
|
|
2276
|
+
}
|
|
2251
2277
|
}
|
|
2252
2278
|
const sectionRef = react.useRef(null);
|
|
2253
2279
|
const [compCardTop, setCompCardTop] = react.useState(null);
|
|
@@ -4804,7 +4830,7 @@ window.__ModuleLoader__.load({
|
|
|
4804
4830
|
? (repoLandResult.ok === true
|
|
4805
4831
|
? el("div", { className: styles.rowTop },
|
|
4806
4832
|
el("p", { className: styles.message, style: { flex: 1, minWidth: 0, wordBreak: "break-all" } }, t("repoLandDone") + repoLandResult.path),
|
|
4807
|
-
el("button", { type: "button", className: styles.toggle, onClick: repoLandOpenFolder }, t("repoLandOpenFolder")))
|
|
4833
|
+
el("button", { type: "button", className: styles.toggle, onClick: () => repoLandOpenFolder(repoLandResult.path) }, t("repoLandOpenFolder")))
|
|
4808
4834
|
: el("p", { className: styles.message, "data-error": "true" }, t("safetySwitchFailed") + ":" + (repoLandResult.error ?? "?")))
|
|
4809
4835
|
: null,
|
|
4810
4836
|
el("strong", { className: styles.name }, t("repoLandList") + "(" + repoLanded.length + ")"),
|
|
@@ -4814,7 +4840,7 @@ window.__ModuleLoader__.load({
|
|
|
4814
4840
|
repoLanded.map((item) =>
|
|
4815
4841
|
el("div", { key: item.repo, className: styles.rowTop },
|
|
4816
4842
|
el("strong", { className: styles.name, style: { flex: "1 1 auto" } }, item.repo),
|
|
4817
|
-
el("button", { type: "button", className: styles.toggle, onClick: () =>
|
|
4843
|
+
el("button", { type: "button", className: styles.toggle, onClick: () => repoLandOpenFolder(item.path) }, t("repoLandOpenFolder")),
|
|
4818
4844
|
el("button", { type: "button", className: styles.trashBtn, title: t("repoLandRemoveConfirm"), "aria-label": t("repoLandRemoveConfirm"), onClick: () => repoLandRemove(item) }, "✕"))))
|
|
4819
4845
|
)))
|
|
4820
4846
|
: null,
|
|
@@ -5405,10 +5431,30 @@ window.__ModuleLoader__.load({
|
|
|
5405
5431
|
onClick: () => setDismissedFailures((prev) => ({ ...prev, [job.jobId]: true })),
|
|
5406
5432
|
}, "✕")),
|
|
5407
5433
|
el("p", { className: styles.message, "data-error": "true" }, job.error ?? ""),
|
|
5434
|
+
// 子目录插件(2026-10-13 加法,真机 Fei2411/DeepSeek-Bot):服务端已判定"插件在
|
|
5435
|
+
// <子目录>/ 下",这里把结论**单独显示**(不用用户从一整段报错里找路径),
|
|
5436
|
+
// 并给出可复制的下一步(子目录路径 / 包名)。用 styles.message、不加新 class。
|
|
5437
|
+
job.subplugin !== null && job.subplugin !== undefined
|
|
5438
|
+
? el("p", { className: styles.message, "data-warn": "true" },
|
|
5439
|
+
t("subpluginFound")
|
|
5440
|
+
.replace("{dir}", String(job.subplugin.dir ?? "?"))
|
|
5441
|
+
.replace("{pkg}", String(job.subplugin.packageName ?? "?"))
|
|
5442
|
+
.replace("{markers}", Array.isArray(job.subplugin.markers) ? job.subplugin.markers.join(" / ") : "?"))
|
|
5443
|
+
: null,
|
|
5444
|
+
job.subplugin !== null && job.subplugin !== undefined && typeof job.subplugin.dir === "string" && job.subplugin.dir !== ""
|
|
5445
|
+
? el("div", { className: styles.rowTop },
|
|
5446
|
+
el("button", { type: "button", className: styles.toggle, onClick: () => copyInstallCommand(String(job.subplugin.dir)) }, t("subpluginCopyDir")),
|
|
5447
|
+
typeof job.subplugin.packageName === "string" && job.subplugin.packageName !== ""
|
|
5448
|
+
? el("button", { type: "button", className: styles.toggle, onClick: () => copyInstallCommand(String(job.subplugin.packageName)) }, t("subpluginCopyName"))
|
|
5449
|
+
: null)
|
|
5450
|
+
: null,
|
|
5408
5451
|
...diagLines(job).map(diagLineEl),
|
|
5409
5452
|
el("div", { className: styles.rowTop },
|
|
5410
5453
|
el("button", { type: "button", className: styles.toggle, onClick: () => startJob(job.repo, job.packageName, job.source ?? "github") }, t("retry")),
|
|
5411
|
-
|
|
5454
|
+
// 「仓库落地」一键克隆:hint=repo-land(旧结论)与 hint=subdir-plugin(插件在子目录里)
|
|
5455
|
+
// 都要给 —— 后者的文案本身就写着"先按「仓库落地」克隆到本地查看该子目录",
|
|
5456
|
+
// 按钮若只在 repo-land 时渲染,那句话就指向一个不存在的入口。
|
|
5457
|
+
job.hint === "repo-land" || job.hint === "subdir-plugin"
|
|
5412
5458
|
? el("button", { type: "button", className: styles.toggle, title: repoLandTitleText(), onClick: () => { setRepoLandOpen(true); repoLandEntry(job.repo); } }, t("repoLandEntry"))
|
|
5413
5459
|
: null)),
|
|
5414
5460
|
),
|
|
@@ -9,7 +9,10 @@
|
|
|
9
9
|
// 注册前先按本模块读出的引用逐个 `resolvePackageJson` 核实,缺失即拒绝注册。
|
|
10
10
|
|
|
11
11
|
import { existsSync, readFileSync } from 'node:fs'
|
|
12
|
-
import {
|
|
12
|
+
import { readFile } from 'node:fs/promises'
|
|
13
|
+
import { dirname, join } from 'node:path'
|
|
14
|
+
import { DEFAULT_BUNDLES } from './install.js'
|
|
15
|
+
import { resolvePackageJson } from '../infra/paths.js'
|
|
13
16
|
|
|
14
17
|
/** 读取聚合包 `cordis.patch.yml` 的 insert 行 name 列表(注册前校验用)。
|
|
15
18
|
* 解析形状与 install.js#ensureBundlePatchIntegrity 同构:`- insert:` 块下的
|
|
@@ -45,4 +48,41 @@ function readBundlePatchRefNames(profileDir, pkgName) {
|
|
|
45
48
|
} catch { return [] }
|
|
46
49
|
}
|
|
47
50
|
|
|
48
|
-
|
|
51
|
+
/** 读取用户额外 bundle(非官方模板)的补丁插入行 id 与包名,用于"额外插件"判定。
|
|
52
|
+
* 2026-10-13 从 install-job.js 原样搬出(只搬移未改逻辑;那边贴着 600 行硬顶,
|
|
53
|
+
* 而这块与上面的 insert 行解析是同一件事),调用方经 install-job.js 的 re-export 取用,
|
|
54
|
+
* 对外导出名与行为一字未变(tests/test-architecture-guard 的接线断言要求导出名仍在)。 */
|
|
55
|
+
async function readExtraBundleRows(profileDir) {
|
|
56
|
+
const rows = new Set()
|
|
57
|
+
try {
|
|
58
|
+
const manifest = JSON.parse(await readFile(join(profileDir, 'package.json'), 'utf8'))
|
|
59
|
+
const bundles = manifest.dsh?.profile?.bundles ?? []
|
|
60
|
+
for (const pkg of bundles) {
|
|
61
|
+
if (DEFAULT_BUNDLES.includes(pkg)) continue
|
|
62
|
+
try {
|
|
63
|
+
const pk = resolvePackageJson(pkg, profileDir)
|
|
64
|
+
const dir = dirname(pk ?? join(profileDir, 'node_modules', ...String(pkg).split('/'), 'package.json'))
|
|
65
|
+
const text = await readFile(join(dir, 'cordis.patch.yml'), 'utf8')
|
|
66
|
+
const lines = text.split(/\r?\n/u)
|
|
67
|
+
let inInsert = false
|
|
68
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
69
|
+
const line = lines[index]
|
|
70
|
+
if (/^- insert:\s*$/u.test(line)) {
|
|
71
|
+
inInsert = true
|
|
72
|
+
continue
|
|
73
|
+
}
|
|
74
|
+
if (/^- /u.test(line)) inInsert = false
|
|
75
|
+
if (!inInsert) continue
|
|
76
|
+
const idMatch = line.match(/^ {4}- id: ([A-Za-z0-9_.-]+)\s*$/u)
|
|
77
|
+
if (!idMatch) continue
|
|
78
|
+
rows.add(idMatch[1])
|
|
79
|
+
const nameMatch = (lines[index + 1] ?? '').match(/^ {6}name: ['"]([^'"]+)['"]\s*$/u)
|
|
80
|
+
if (nameMatch) rows.add(nameMatch[1])
|
|
81
|
+
}
|
|
82
|
+
} catch {}
|
|
83
|
+
}
|
|
84
|
+
} catch {}
|
|
85
|
+
return rows
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export { readBundlePatchRefNames, readExtraBundleRows }
|
|
@@ -2,22 +2,22 @@
|
|
|
2
2
|
// 分层 Step 8c-2 从 lib/index.js 搬出;形参由 ctx 收窄为 ports(调用方注入的窄接口),只搬移未改逻辑。
|
|
3
3
|
// 分层分组:L1 · domain(边界由 tests/test-architecture-guard.mjs 断言)
|
|
4
4
|
|
|
5
|
-
import { readFile } from 'node:fs/promises'
|
|
6
5
|
import { dirname, join, resolve } from 'node:path'
|
|
7
6
|
import { aiRepair } from './ai-run.js'
|
|
8
7
|
import { maybeAutoAdaptCompat } from './compat.js'
|
|
9
8
|
import { getReposDir } from './repoland.js'
|
|
10
9
|
import { aiConsentFailureText, cleanupAttemptedCandidates, pnpmRemove } from './install-cleanup.js'
|
|
11
10
|
import { GIT_CHANNEL_BUDGET_MS, GIT_MIN_ATTEMPT_MS, GIT_SPEC_TIMEOUT_MS, gitChannelBudgetMs, noteChannel, formatBudgetMs, tryGitChannel } from './git-channel.js'
|
|
12
|
-
import {
|
|
13
|
-
import { readBundlePatchRefNames } from './bundle-refs.js' // 0.5.
|
|
11
|
+
import { AI_CONSENT_TIMEOUT_MS, PNPM_INSTALL_TIMEOUT_MS, addBundleToManifest, backfillMissingDeps, curlManualInstall, detectBundleOnly, ensureBundlePatchIntegrity, githubReleaseInstall, pnpmInstall, raceInstallChannels, readGithubAuth, syncAggregateSubpackageVersions } from './install.js'
|
|
12
|
+
import { readBundlePatchRefNames, readExtraBundleRows } from './bundle-refs.js' // re-export(0.5.46 搬入;0.5.26 已搬 readBundlePatchRefNames)
|
|
14
13
|
import { classifyInstallFailure, retryPnpmOnce } from './install-diagnose.js'
|
|
15
14
|
import { jobLog } from './jobs.js'
|
|
16
15
|
import { gatePrivateRoot } from './repo-size.js'
|
|
17
16
|
import { tryPresetSourceChannel } from './preset-source.js'
|
|
18
17
|
import { settlePresetOutcome } from './preset-in-package.js'
|
|
19
18
|
import { alreadyInstalledTarget, verifyInstalledEntry } from './install-verify.js'
|
|
20
|
-
import { applyNpmNameHint, expandSubpackageCandidates, fetchRepoPackage, fetchRepoPackageEx, fetchSubpackageNames, npmNameHintForRepo, packageProbeErrorText, subpackageCandidates } from './market.js'
|
|
19
|
+
import { applyNpmNameHint, expandSubpackageCandidates, fetchRepoPackage, fetchRepoPackageEx, fetchSubdirPlugins, fetchSubpackageNames, npmNameHintForRepo, packageProbeErrorText, subpackageCandidates } from './market.js'
|
|
20
|
+
import { decideNoRootManifest } from './subdir-detect.js'
|
|
21
21
|
import { finalizeInstallPersistence } from './install-finalize.js'
|
|
22
22
|
import { readPatchState, syncNameFromNote } from './patch.js'
|
|
23
23
|
import { listEntries } from './runtime.js'
|
|
@@ -222,6 +222,7 @@ async function runInstallJob(job, ports, deps = {}) {
|
|
|
222
222
|
fetchRepoPackage,
|
|
223
223
|
fetchRepoPackageEx,
|
|
224
224
|
fetchSubpackageNames,
|
|
225
|
+
fetchSubdirPlugins,
|
|
225
226
|
subpackageCandidates,
|
|
226
227
|
npmNameHint: npmNameHintForRepo,
|
|
227
228
|
...(deps.marketProbes ?? {}),
|
|
@@ -287,17 +288,31 @@ async function runInstallJob(job, ports, deps = {}) {
|
|
|
287
288
|
const { pkg, reason } = rootProbe
|
|
288
289
|
if (pkg === null) {
|
|
289
290
|
// 无 package.json:先探测是否技能仓库(含 SKILL.md)→ 自动转技能安装;
|
|
290
|
-
//
|
|
291
|
+
// 2026-10-13 改错(用户实测 Fei2411/DeepSeek-Bot):**根目录没有清单 ≠ 这个仓库不是插件**。
|
|
292
|
+
// 真机该仓库根目录只有 docs/ + ds-bot/,而 ds-bot/package.json 同时带 dsh.client 与
|
|
293
|
+
// dsh.bundle.patch —— 旧代码在这里直接下"不是插件"的结论,结论错、也没给用户下一步。
|
|
294
|
+
// 现在先向下扫 1–2 层子目录(与 fetchSubpackageNames 的路径口径一致):
|
|
295
|
+
// · 扫到带 dsh 标记的清单 → 明说"插件在 <子目录>/ 下"并给出可复制的安装写法;
|
|
296
|
+
// · 扫完确实没有 → 保持旧结论,但文案补上"已检查根目录与 N 层子目录"(诚实)。
|
|
291
297
|
const skillProbe = await detectSkillRepo(job.repo, branch)
|
|
292
298
|
if (skillProbe.hasSkill) {
|
|
293
299
|
job.kind = 'skill'
|
|
294
300
|
await runSkillInstallJob(job, ports)
|
|
295
301
|
return
|
|
296
302
|
}
|
|
303
|
+
const decided = await decideNoRootManifest({
|
|
304
|
+
repo: job.repo,
|
|
305
|
+
branch,
|
|
306
|
+
reason,
|
|
307
|
+
probe: probes.fetchSubdirPlugins,
|
|
308
|
+
baseText: packageProbeErrorText(job.repo, branch, reason),
|
|
309
|
+
source: job.source,
|
|
310
|
+
})
|
|
297
311
|
job.status = 'failed'
|
|
298
|
-
job.hint =
|
|
299
|
-
|
|
300
|
-
job.
|
|
312
|
+
job.hint = decided.hint
|
|
313
|
+
job.error = decided.error
|
|
314
|
+
job.subplugin = decided.subplugin
|
|
315
|
+
if (decided.hint === 'subdir-plugin') return
|
|
301
316
|
job.probeReason = reason
|
|
302
317
|
return
|
|
303
318
|
}
|
|
@@ -562,37 +577,9 @@ async function runInstallJob(job, ports, deps = {}) {
|
|
|
562
577
|
job.finishedAt = Date.now()
|
|
563
578
|
}
|
|
564
579
|
}
|
|
565
|
-
/** 读取用户额外 bundle(非官方模板)的补丁插入行 id 与包名,用于"额外插件"判定。
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
for (const pkg of bundles) {
|
|
572
|
-
if (DEFAULT_BUNDLES.includes(pkg)) continue
|
|
573
|
-
try {
|
|
574
|
-
const pk = resolvePackageJson(pkg, profileDir)
|
|
575
|
-
const dir = dirname(pk ?? join(profileDir, 'node_modules', ...String(pkg).split('/'), 'package.json'))
|
|
576
|
-
const text = await readFile(join(dir, 'cordis.patch.yml'), 'utf8')
|
|
577
|
-
const lines = text.split(/\r?\n/u)
|
|
578
|
-
let inInsert = false
|
|
579
|
-
for (let index = 0; index < lines.length; index += 1) {
|
|
580
|
-
const line = lines[index]
|
|
581
|
-
if (/^- insert:\s*$/u.test(line)) {
|
|
582
|
-
inInsert = true
|
|
583
|
-
continue
|
|
584
|
-
}
|
|
585
|
-
if (/^- /u.test(line)) inInsert = false
|
|
586
|
-
if (!inInsert) continue
|
|
587
|
-
const idMatch = line.match(/^ {4}- id: ([A-Za-z0-9_.-]+)\s*$/u)
|
|
588
|
-
if (!idMatch) continue
|
|
589
|
-
rows.add(idMatch[1])
|
|
590
|
-
const nameMatch = (lines[index + 1] ?? '').match(/^ {6}name: ['"]([^'"]+)['"]\s*$/u)
|
|
591
|
-
if (nameMatch) rows.add(nameMatch[1])
|
|
592
|
-
}
|
|
593
|
-
} catch {}
|
|
594
|
-
}
|
|
595
|
-
} catch {}
|
|
596
|
-
return rows
|
|
597
|
-
}
|
|
598
|
-
export { runInstallJob, pnpmRemove, readExtraBundleRows, cleanupAttemptedCandidates, aiConsentFailureText, tryCandidateChannels, channelImpls, RELEASE_CHANNEL_BUDGET, GIT_CHANNEL_BUDGET_MS, GIT_SPEC_TIMEOUT_MS, GIT_MIN_ATTEMPT_MS, gitChannelBudgetMs, noteChannel, formatBudgetMs }
|
|
580
|
+
/** 读取用户额外 bundle(非官方模板)的补丁插入行 id 与包名,用于"额外插件"判定。
|
|
581
|
+
* 实现在 domain/bundle-refs.js(2026-10-13 搬出,只搬移未改逻辑:那边与 insert 行解析同属一件事,
|
|
582
|
+
* 这边腾出 600 行硬顶);这里保留同名 re-export —— **对外导出名与调用点一个字都没变**。 */
|
|
583
|
+
export { readExtraBundleRows }
|
|
584
|
+
|
|
585
|
+
export { runInstallJob, pnpmRemove, cleanupAttemptedCandidates, aiConsentFailureText, tryCandidateChannels, channelImpls, RELEASE_CHANNEL_BUDGET, GIT_CHANNEL_BUDGET_MS, GIT_SPEC_TIMEOUT_MS, GIT_MIN_ATTEMPT_MS, gitChannelBudgetMs, noteChannel, formatBudgetMs }
|
|
@@ -536,6 +536,10 @@ function installJobView(job) {
|
|
|
536
536
|
suiteNote: job.suiteNote ?? null, repoSize: job.repoSize ?? null,
|
|
537
537
|
...presetJobView(job),
|
|
538
538
|
hint: job.hint ?? null,
|
|
539
|
+
// 子目录插件识别结果(2026-10-13 加法,真机 Fei2411/DeepSeek-Bot:插件在 ds-bot/ 而不是仓库根)。
|
|
540
|
+
// 下发结构化字段而不只是塞进 error 文案:面板要能**单独把它显示出来**(插件在哪个子目录、
|
|
541
|
+
// 包名、哪些 dsh 标记),用户才不用从一整段报错里找路径。老客户端忽略该字段(纯加法)。
|
|
542
|
+
subplugin: job.subplugin ?? null,
|
|
539
543
|
// 子包级/套装级进度(2026-09-20 真装实测缺口:11 个子包的聚合仓库跑了 19 分钟,面板只显示
|
|
540
544
|
// "安装中",用户不知道在装第几个、还剩几个)。子包通道来自 install-job.js 候选循环维护的
|
|
541
545
|
// candidateXxx;套装通道来自 suite.js 维护的 suiteProgress(clone/装配两阶段)。
|
|
@@ -5,6 +5,7 @@ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
|
|
|
5
5
|
import { readFile } from 'node:fs/promises'
|
|
6
6
|
import { dirname, join } from 'node:path'
|
|
7
7
|
import { detectSkillRepo } from './skills.js'
|
|
8
|
+
import { dshMarkersOf } from './subdir-detect.js'
|
|
8
9
|
import { FETCH_OK, FETCH_UNREACHABLE, GITHUB_API, curlJson, curlText, fetchJsonUrl, githubJson, looksLikeGitmodules, rawTextFetch, rawTextWithFallback } from '../infra/http.js'
|
|
9
10
|
import { ENRICH_CACHE_FILE, baseDirOf, marketIndexCacheFile, resolvePackageJson } from '../infra/paths.js'
|
|
10
11
|
|
|
@@ -379,7 +380,11 @@ function subpackageBranchOrder(branch, defaults = ['main', 'master']) {
|
|
|
379
380
|
return [...new Set(merged.map((b) => b.trim()))]
|
|
380
381
|
}
|
|
381
382
|
|
|
382
|
-
/** 单个分支上读子包列表(git trees 递归 + 并行读 package.json 的 name)。
|
|
383
|
+
/** 单个分支上读子包列表(git trees 递归 + 并行读 package.json 的 name)。
|
|
384
|
+
* 2026-10-13 加法:每行多带一个 `markers`(该清单里的 `dsh.bundle` / `dsh.client` 标记,见
|
|
385
|
+
* subdir-detect.dshMarkersOf)——**零额外网络请求**(package.json 本来就要读来取 name),
|
|
386
|
+
* 让上层能回答"这个子目录是不是 DSH 插件",而不是只知道"这里有个包"。
|
|
387
|
+
* 老调用方(只读 name)签名与语义一字未变(多一个字段属纯加法)。 */
|
|
383
388
|
async function fetchSubpackageNamesOnBranch(repo, branch, auth, { ghJson, rawText }) {
|
|
384
389
|
try {
|
|
385
390
|
const data = await ghJson(`${GITHUB_API}/repos/${repo}/git/trees/${encodeURIComponent(branch)}?recursive=1`, undefined, auth)
|
|
@@ -392,7 +397,7 @@ async function fetchSubpackageNamesOnBranch(repo, branch, auth, { ghJson, rawTex
|
|
|
392
397
|
if (bodyText === null) return null
|
|
393
398
|
try {
|
|
394
399
|
const pkg = JSON.parse(bodyText)
|
|
395
|
-
if (pkg && typeof pkg.name === 'string') return { dir: path.split('/')[1], path: path.split('/').slice(0, -1).join('/'), name: pkg.name }
|
|
400
|
+
if (pkg && typeof pkg.name === 'string') return { dir: path.split('/')[1], path: path.split('/').slice(0, -1).join('/'), name: pkg.name, markers: dshMarkersOf(pkg) }
|
|
396
401
|
} catch {}
|
|
397
402
|
return null
|
|
398
403
|
}))
|
|
@@ -402,6 +407,32 @@ async function fetchSubpackageNamesOnBranch(repo, branch, auth, { ghJson, rawTex
|
|
|
402
407
|
}
|
|
403
408
|
}
|
|
404
409
|
|
|
410
|
+
/** 仓库子目录插件识别(2026-10-13 加法,真机 `Fei2411/DeepSeek-Bot`):
|
|
411
|
+
* 根目录没有 package.json 时**向下找** `dsh` 插件(1–2 层,与上面的路径口径一致)。
|
|
412
|
+
* 返回 `[{ path, name, markers }]`,path 为相对仓库根的目录(如 `ds-bot`)。
|
|
413
|
+
* 分支按 `subpackageBranchOrder`(已拿到的默认分支打头,再回退 main/master)。
|
|
414
|
+
* **失败与"确实没有"必须分得开**:扫描抛错/读不到 → 返回 `null`("没读到");
|
|
415
|
+
* 扫完确实一个都没有 → 返回 `[]`("没有")。上层文案据此决定是"未发现"还是
|
|
416
|
+
* "子目录这一轮没能完整读到,结论只覆盖读到的部分"(`decideNoRootManifest` 的 scanFailed)。
|
|
417
|
+
* deps 只为单测注入,生产调用不传;**原样透传给 fetchSubpackageNames**(同一个 deps 袋子:
|
|
418
|
+
* `{ githubJson, rawText }` 走真实现,或 `{ fetchSubpackageNames: 桩 }` 整体替换)——
|
|
419
|
+
* 上一版用 `deps.fetchDeps` 又包了一层,结果单测的桩拿到空袋子(`rawText is not a function`)。 */
|
|
420
|
+
async function fetchSubdirPlugins(repo, branch, auth = null, deps = {}) {
|
|
421
|
+
const fetchNames = typeof deps.fetchSubpackageNames === 'function' ? deps.fetchSubpackageNames : fetchSubpackageNames
|
|
422
|
+
let list = null
|
|
423
|
+
try {
|
|
424
|
+
list = await fetchNames(repo, branch, auth, deps)
|
|
425
|
+
} catch {
|
|
426
|
+
return null
|
|
427
|
+
}
|
|
428
|
+
if (!Array.isArray(list)) return null
|
|
429
|
+
return list
|
|
430
|
+
.filter((item) => item !== null && typeof item === 'object' && typeof item.name === 'string' && item.name !== '')
|
|
431
|
+
.map((item) => ({ path: String(item.path ?? item.dir ?? ''), name: item.name, markers: Array.isArray(item.markers) ? item.markers : [] }))
|
|
432
|
+
.filter((item) => item.path !== '')
|
|
433
|
+
.slice(0, 12)
|
|
434
|
+
}
|
|
435
|
+
|
|
405
436
|
/** 从市场索引里查这个仓库的**首选 npm 包名**(批次 C-⑪,2026-09-27 加法)。
|
|
406
437
|
* 数据源与 /market-index 完全一致:routes/market.js 每次成功拉到索引都会写一份落盘缓存
|
|
407
438
|
* (infra/paths.marketIndexCacheFile),这里读它 —— 与内存缓存等价、且跨重启有效。
|
|
@@ -460,12 +491,30 @@ async function expandSubpackageCandidates({ repo, branch = 'main', auth = null,
|
|
|
460
491
|
.slice(0, limit)
|
|
461
492
|
}
|
|
462
493
|
|
|
494
|
+
// ── 子包探针的出网回落值收口(可注入 + 红线保险;2026-10-13)────────────────────────────
|
|
495
|
+
// 为什么要有这一层:`test-subdir-plugin.mjs` 的"全程离线"是靠**把桩塞进 deps 袋子**保证的,
|
|
496
|
+
// 而 `fetchSubpackageNames` 在 deps 缺项时会**回落到真实现**(下面的 githubJson /
|
|
497
|
+
// rawTextWithFallback)—— 一旦哪天桩的袋子没接上(本文件已有一次前科:上一版用 deps.fetchDeps
|
|
498
|
+
// 多包了一层,单测的桩拿到空袋子),测试会**先真的发出 GitHub 请求**、然后才红。
|
|
499
|
+
// 那不算守卫("真出网了也只是事后报错")。所以把这两个回落值收成**唯一可注入点**:
|
|
500
|
+
// ① 注入缝:单测 `__setSubpkgProbeDefaults({ ghJson, rawText })` 换成**会抛的 tripwire**
|
|
501
|
+
// ⇒ 真有代码走到出网,当场红,且一个请求都发不出去(预防,不是事后取证);
|
|
502
|
+
// ② 生产不注入(null)⇒ 回落值与改动前逐字相同(githubJson / rawTextWithFallback),行为零变化。
|
|
503
|
+
// 与 routes/components.js 的 `__setOpenFolderSpawn` 同一套做法(那边管进程,这边管出网)。
|
|
504
|
+
let probeEgressDefaults = null
|
|
505
|
+
|
|
506
|
+
/** 单测注入子包探针的出网回落值:传对象 = 打桩(**不发任何真实请求**);传 null = 还原生产实现。 */
|
|
507
|
+
function __setSubpkgProbeDefaults(deps) {
|
|
508
|
+
probeEgressDefaults = deps !== null && typeof deps === 'object' ? deps : null
|
|
509
|
+
return probeEgressDefaults !== null
|
|
510
|
+
}
|
|
511
|
+
|
|
463
512
|
/** 服务端列出仓库子包(git trees 递归 + 并行读 package.json 的 name)。
|
|
464
513
|
* branch 可以是**单个分支名**(老调用点不变)或**分支数组**(按序试);两种形态都会自动
|
|
465
514
|
* 追加 main / master 兜底(见 subpackageBranchOrder)。deps 只为单测注入,生产调用不传。 */
|
|
466
515
|
async function fetchSubpackageNames(repo, branch, auth, deps = {}) {
|
|
467
|
-
const ghJson = deps.githubJson ?? githubJson
|
|
468
|
-
const rawText = deps.rawText ?? rawTextWithFallback
|
|
516
|
+
const ghJson = deps.githubJson ?? probeEgressDefaults?.ghJson ?? githubJson
|
|
517
|
+
const rawText = deps.rawText ?? probeEgressDefaults?.rawText ?? rawTextWithFallback
|
|
469
518
|
for (const b of subpackageBranchOrder(branch)) {
|
|
470
519
|
// eslint-disable-next-line no-await-in-loop
|
|
471
520
|
const subs = await fetchSubpackageNamesOnBranch(repo, b, auth, { ghJson, rawText })
|
|
@@ -489,4 +538,4 @@ async function subpackageCandidates(repo, branch, auth) {
|
|
|
489
538
|
|
|
490
539
|
const ENRICH_CACHE_TTL = 24 * 60 * 60 * 1000
|
|
491
540
|
|
|
492
|
-
export { readEnrichCache, writeEnrichCache, enrichItemOne, enrichItems, normalizePlatformItems, githubRepoInfo, fetchRepoPackage, fetchRepoPackageEx, packageProbeErrorText, searchNpmPackages, searchSubpackageItems, parseRepoFromUrl, hasDirectNameHit, summarizeReadme, readPluginDetails, fetchSubpackageNames, fetchSubpackageNamesOnBranch, subpackageBranchOrder, subpackageCandidates, expandSubpackageCandidates, npmNameHintForRepo, applyNpmNameHint, ENRICH_CACHE_TTL }
|
|
541
|
+
export { readEnrichCache, writeEnrichCache, enrichItemOne, enrichItems, normalizePlatformItems, githubRepoInfo, fetchRepoPackage, fetchRepoPackageEx, packageProbeErrorText, searchNpmPackages, searchSubpackageItems, parseRepoFromUrl, hasDirectNameHit, summarizeReadme, readPluginDetails, fetchSubpackageNames, fetchSubpackageNamesOnBranch, fetchSubdirPlugins, subpackageBranchOrder, subpackageCandidates, expandSubpackageCandidates, npmNameHintForRepo, applyNpmNameHint, ENRICH_CACHE_TTL, __setSubpkgProbeDefaults }
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// L1 · domain —— subdir-detect.js(仓库子目录插件识别)
|
|
2
|
+
//
|
|
3
|
+
// 为什么单开一个文件:install-job.js 已经贴着 600 行硬顶(tests/test-architecture-guard.mjs 断言),
|
|
4
|
+
// 而"根目录没有 package.json 时向下扫子目录"是一块自洽的只读逻辑,搬出来还能独立单测。
|
|
5
|
+
//
|
|
6
|
+
// 真机现场(2026-10-13 用户实测):`Fei2411/DeepSeek-Bot`(topics 含 dsh-plugin)根目录**没有**
|
|
7
|
+
// package.json —— 但 `ds-bot/package.json` 同时声明了 `dsh.client` 与 `dsh.bundle.patch`,
|
|
8
|
+
// 是不折不扣的 DSH 插件。旧判定只看仓库根,于是给用户一句"不是插件",
|
|
9
|
+
// 既不准(结论错)也不给路(没有下一步)。判据:**向下扫 1–2 层**(`<dir>/package.json`、
|
|
10
|
+
// `<dir>/<sub>/package.json`,与 fetchSubpackageNames 的路径口径完全一致),
|
|
11
|
+
// 命中带 dsh 标记的清单就明说"插件在 <子目录>/ 下",扫完确实没有才保持旧结论 ——
|
|
12
|
+
// 且文案必须补上"已检查根目录与 N 层子目录"(诚实:让用户知道我们看过哪里)。
|
|
13
|
+
//
|
|
14
|
+
// 分层:本文件属 L1 · domain —— 不得出现 cordis ctx(test-architecture-guard 断言⑤)。
|
|
15
|
+
|
|
16
|
+
/** 清单里的 DSH 插件标记(`dsh.bundle` / `dsh.client` 任一)。
|
|
17
|
+
* 判据与官方安装器一致:`dsh.bundle.patch` 说明是 bundle 层包(其 cordis.patch.yml 会被组合进树),
|
|
18
|
+
* `dsh.client` 说明带 UI 入口 —— 两者任一即"这是给 DSH 用的包"。
|
|
19
|
+
* 纯函数(单测覆盖):JSON 解析失败、`dsh` 不是对象、字段类型不对,一律返回 [],绝不抛。 */
|
|
20
|
+
function dshMarkersOf(pkg) {
|
|
21
|
+
const out = []
|
|
22
|
+
try {
|
|
23
|
+
if (pkg === null || typeof pkg !== 'object') return out
|
|
24
|
+
const dsh = pkg.dsh
|
|
25
|
+
if (dsh !== null && typeof dsh === 'object') {
|
|
26
|
+
if (typeof dsh.bundle?.patch === 'string' && dsh.bundle.patch !== '') out.push('dsh.bundle.patch')
|
|
27
|
+
if (dsh.client !== null && dsh.client !== undefined) out.push('dsh.client')
|
|
28
|
+
}
|
|
29
|
+
} catch {
|
|
30
|
+
return []
|
|
31
|
+
}
|
|
32
|
+
return out
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 读一段 package.json 文本(已从网络/磁盘拿到)→ 归一化的包信息。
|
|
36
|
+
* 返回 `{ name, markers, privateRoot }`;解析失败或没有合法名字返回 null
|
|
37
|
+
* ("读到了但不算包"与"没读到"是两件事,这里只管前者,调用方负责后者)。 */
|
|
38
|
+
function describeDshManifest(text) {
|
|
39
|
+
try {
|
|
40
|
+
const pkg = JSON.parse(text)
|
|
41
|
+
if (pkg === null || typeof pkg !== 'object') return null
|
|
42
|
+
if (typeof pkg.name !== 'string' || pkg.name === '') return null
|
|
43
|
+
return { name: pkg.name, markers: dshMarkersOf(pkg), privateRoot: pkg.private === true }
|
|
44
|
+
} catch {
|
|
45
|
+
return null
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 把"已知子包清单"(各行含 path / name / markers)折成**用户可读的结论**(纯函数,单测覆盖)。
|
|
50
|
+
*
|
|
51
|
+
* `subs` 形态与 market.js 的 `fetchSubdirPlugins` 返回值一致:
|
|
52
|
+
* `[{ path: 'ds-bot', name: 'ds-bot', markers: ['dsh.client', 'dsh.bundle.patch'] }, …]`
|
|
53
|
+
*
|
|
54
|
+
* 返回:
|
|
55
|
+
* · found=true → `{ dir, packageName, markers, checkedDirs }`(插件在哪个子目录)
|
|
56
|
+
* · found=false → `{ checkedDirs }`(真扫过这些目录,一个 dsh 清单都没有)
|
|
57
|
+
* `checkedDirs` 永远如实列出**本次真的读到清单的那些目录**(没读到的用 `scanned` 计数带过),
|
|
58
|
+
* 这样文案里那句"已检查 N 层子目录"是有据可查的,而不是一句空话。 */
|
|
59
|
+
function pickSubdirPlugin(subs) {
|
|
60
|
+
const list = (Array.isArray(subs) ? subs : []).filter((s) => s !== null && typeof s === 'object')
|
|
61
|
+
const checkedDirs = list.map((s) => String(s.path ?? s.dir ?? '')).filter((d) => d !== '')
|
|
62
|
+
const hit = list.find((s) => Array.isArray(s.markers) && s.markers.length > 0)
|
|
63
|
+
if (hit === undefined) return { found: false, checkedDirs }
|
|
64
|
+
return {
|
|
65
|
+
found: true,
|
|
66
|
+
dir: String(hit.path ?? hit.dir ?? ''),
|
|
67
|
+
packageName: typeof hit.name === 'string' && hit.name !== '' ? hit.name : String(hit.path ?? '').split('/').pop(),
|
|
68
|
+
markers: [...hit.markers],
|
|
69
|
+
checkedDirs,
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 命中子目录插件时的用户可读结论(纯函数,单测覆盖)。
|
|
74
|
+
* 要点(对应用户实测的质疑"它不是 DSH 插件吗"):
|
|
75
|
+
* ① 先**纠正结论** —— 这不是"不是插件",而是"插件不在根目录";
|
|
76
|
+
* ② 给出**子目录路径**与**包名**,以及标记来源(dsh.bundle.patch / dsh.client)—— 判据可核验;
|
|
77
|
+
* ③ 给出**可复制的下一步**(按包名安装);并**如实说明**"从子目录直接装"当前不支持
|
|
78
|
+
* (git 通道只装仓库根包,见 domain/git-channel.js 的核实逻辑)—— 不假装支持。 */
|
|
79
|
+
function subdirPluginText(repo, branch, pick, source = 'github') {
|
|
80
|
+
const dir = String(pick?.dir ?? '')
|
|
81
|
+
const pkgName = String(pick?.packageName ?? '')
|
|
82
|
+
const markers = Array.isArray(pick?.markers) ? pick.markers.join('、') : ''
|
|
83
|
+
return `仓库 ${repo}(${branch} 分支)根目录没有 package.json,但**插件在子目录 ${dir}/ 下**`
|
|
84
|
+
+ `(${dir}/package.json 声明了 DSH 标记:${markers})。`
|
|
85
|
+
+ `它就是 DSH 插件,只是清单不在仓库根——${pkgName === '' ? '该子包的包名没能读到' : `包名是 ${pkgName}`}。`
|
|
86
|
+
+ `下一步:${pkgName === '' ? '先按「仓库落地」克隆到本地查看该子目录,' : `该包若已发布到 npm,可直接安装:dsh plugin --profile <你的 profile> add ${pkgName};否则`}`
|
|
87
|
+
+ `本控制台暂不支持"从子目录直接安装"(git 通道只装仓库根包,装了也是根包而不是 ${dir}/),`
|
|
88
|
+
+ `可把该子目录并进一个独立仓库或等作者发布 npm 包后再装。`
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 扫完根目录与子目录都没找到插件时的用户可读结论(纯函数,单测覆盖)。
|
|
92
|
+
* 与 packageProbeErrorText 的分工:那个只管"根目录为什么没读到"(超时/非法/确实没有),
|
|
93
|
+
* 这里补上**本次到底检查了哪里**(子目录层数 + 读到清单的目录名),
|
|
94
|
+
* 让"不是插件"这个结论有据可查,而不是一句没有出处的断言。
|
|
95
|
+
*
|
|
96
|
+
* `scanFailed`(2026-10-13 补):子目录这一轮**没能扫成**(探针抛错 / 返回非数组 / 根本没给探针)时,
|
|
97
|
+
* 结论句必须从"未发现声明 dsh 标记的 package.json"降级成"只覆盖读到的部分" ——
|
|
98
|
+
* 否则一次网络限流就会变成一句比事实更强的断言("没有"≠"没读到")。
|
|
99
|
+
* `reason` 是根清单探测的原因码:`FETCH_UNREACHABLE`('unreachable')同理降级。 */
|
|
100
|
+
function notPluginText(repo, branch, reason, checkedCount, subCount, baseText = '', scanFailed = false) {
|
|
101
|
+
const head = baseText !== '' ? baseText.replace(/。$/u, '') : `仓库 ${repo}(${branch} 分支)不是可安装的插件`
|
|
102
|
+
const partial = scanFailed || reason === 'unreachable' || subCount > checkedCount
|
|
103
|
+
const scope = `已检查根目录与 2 层子目录(本次读到清单的子目录 ${checkedCount} 个`
|
|
104
|
+
+ `${subCount > checkedCount ? `,另有 ${subCount - checkedCount} 个目录清单没读到(可能是网络受限,不代表不存在)` : ''})`
|
|
105
|
+
const verdict = partial
|
|
106
|
+
? ';子目录这一轮没能完整读到,所以"没有 dsh 插件"这条结论只覆盖读到的部分'
|
|
107
|
+
: ',未发现声明 dsh 标记的 package.json'
|
|
108
|
+
return `${head}。${scope}${verdict}。`
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** 根目录没有清单时**唯一**的结论出口(纯函数,单测覆盖):把扫描结果折成
|
|
112
|
+
* 服务端 job 上要写的那几个字段(hint / error / subplugin),调用方(install-job)只剩赋值。
|
|
113
|
+
* 为什么要把出口收成一个函数:两个分支必须**互斥且都给出路** —— 命中子目录插件时文案说
|
|
114
|
+
* "插件在 <子目录>/ 下 + 下一步",没命中时文案必须补"已检查根目录与 2 层子目录"(诚实)。
|
|
115
|
+
* 任何"分支走错一句话"的变异都会被 tests/test-subdir-plugin.mjs 抓住。
|
|
116
|
+
* `probe`(可选)负责真去扫 —— 注入缝与 install-job 的 marketProbes 同一套语义:
|
|
117
|
+
* 传桩函数即可离线断言"扫到了什么就说什么",生产调用传真的 fetchSubdirPlugins。
|
|
118
|
+
* 探针**抛错或返回非数组**(含 fetchSubdirPlugins 的"扫描失败 → null"契约)⇒ `scanFailed=true`:
|
|
119
|
+
* 结论照旧给,但文案降级为"只覆盖读到的部分"("没读到"绝不冒充"没有")。 */
|
|
120
|
+
async function decideNoRootManifest({ repo, branch, reason, subScan = null, probe = null, baseText = '', source = 'github' } = {}) {
|
|
121
|
+
let scan = []
|
|
122
|
+
let scanFailed = false
|
|
123
|
+
if (Array.isArray(subScan)) {
|
|
124
|
+
scan = subScan
|
|
125
|
+
} else if (typeof probe === 'function') {
|
|
126
|
+
try {
|
|
127
|
+
const result = await probe(repo, branch)
|
|
128
|
+
if (Array.isArray(result)) scan = result
|
|
129
|
+
else scanFailed = true
|
|
130
|
+
} catch {
|
|
131
|
+
scanFailed = true
|
|
132
|
+
}
|
|
133
|
+
} else {
|
|
134
|
+
// 没给探针 = 这一轮根本没扫过子目录 —— 不许假装"检查过了"
|
|
135
|
+
scanFailed = true
|
|
136
|
+
}
|
|
137
|
+
const pick = pickSubdirPlugin(scan)
|
|
138
|
+
if (pick.found === true) {
|
|
139
|
+
return {
|
|
140
|
+
hint: 'subdir-plugin',
|
|
141
|
+
subplugin: { dir: pick.dir, packageName: pick.packageName, markers: pick.markers, checkedDirs: pick.checkedDirs },
|
|
142
|
+
error: subdirPluginText(repo, branch, pick, source),
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
hint: 'repo-land',
|
|
147
|
+
subplugin: null,
|
|
148
|
+
error: notPluginText(repo, branch, reason, pick.checkedDirs.length, Array.isArray(scan) ? scan.length : 0, baseText, scanFailed),
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export { dshMarkersOf, describeDshManifest, pickSubdirPlugin, subdirPluginText, notPluginText, decideNoRootManifest }
|
|
@@ -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,159 @@ 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
|
+
// ⇒ 现在把语义**反过来**:真 spawn 必须**显式 opt-in**,缺席时的默认结局是**被拒绝**(不是弹窗)。
|
|
166
|
+
// 进程创建只此一处,四重防线:
|
|
167
|
+
// ① 注入缝:单测 `__setOpenFolderSpawn(stub)` 换成桩(一次进程都不建);
|
|
168
|
+
// ② 保险丝:`DSH_OPEN_FOLDER_NO_SPAWN=1` 时**绝不**走到 spawn —— 如实返回结构化拒绝
|
|
169
|
+
// (不是静默跳过:调用方照旧把这条原因报给用户);
|
|
170
|
+
// ③ **默认安全(opt-in)**:回落到真 `defaultSpawn` 需要 `allowRealSpawn:true` 或
|
|
171
|
+
// `DSH_OPEN_FOLDER_ALLOW_REAL_SPAWN=1`;两者都没有 ⇒ 结构化拒绝。生产的 routeRepoOpen 是
|
|
172
|
+
// 唯一的 opt-in 调用方 —— 于是"测试漏了注入"的默认结局是**红/被拒**,而不是弹用户一个窗口;
|
|
173
|
+
// ④ 计数:真实 spawn 调用次数可读,测试断言恒为 0;`__openFolderSelfCheck()` 是计数器的正控
|
|
174
|
+
// (真的起一次**不弹任何窗口**的进程,证明计数不是死的 —— 桩自校验,本仓既有做法)。
|
|
175
|
+
let injectedSpawn = null
|
|
176
|
+
let realSpawnCalls = 0
|
|
177
|
+
|
|
178
|
+
/** 真 spawn 的**显式 opt-in**(默认拒绝;见 openFolderVerified 第 ③ 条)。 */
|
|
179
|
+
function realSpawnOptIn(allowRealSpawn) {
|
|
180
|
+
return allowRealSpawn === true || process.env.DSH_OPEN_FOLDER_ALLOW_REAL_SPAWN === '1'
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function defaultSpawn(bin, args, options) {
|
|
184
|
+
realSpawnCalls += 1
|
|
185
|
+
return spawn(bin, args, options)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** 单测注入启动桩:传函数 = 打桩(**不创建任何进程**);传 null = 还原生产实现。 */
|
|
189
|
+
function __setOpenFolderSpawn(fn) {
|
|
190
|
+
injectedSpawn = typeof fn === 'function' ? fn : null
|
|
191
|
+
return injectedSpawn !== null
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** 真实子进程创建次数(测试断言本套跑完仍为 0)。 */
|
|
195
|
+
function __openFolderRealSpawnCalls() {
|
|
196
|
+
return realSpawnCalls
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** 计数器正控(桩自校验):用**生产同一个** defaultSpawn 起一次 node -e ""(无窗口、无副作用),
|
|
200
|
+
* 返回它是否被计数到。返回 false ⇒ 计数桩坏了,测试必须据此响亮失败,而不是拿 0 当"没启动过"。 */
|
|
201
|
+
async function __openFolderSelfCheck() {
|
|
202
|
+
const before = realSpawnCalls
|
|
203
|
+
try {
|
|
204
|
+
const child = defaultSpawn(process.execPath, ['-e', ''], { windowsHide: true, stdio: 'ignore' })
|
|
205
|
+
child.on('error', () => {})
|
|
206
|
+
await new Promise((resolve) => { child.on('exit', resolve); setTimeout(resolve, 2000) })
|
|
207
|
+
} catch {
|
|
208
|
+
return { ok: false, counted: realSpawnCalls - before }
|
|
209
|
+
}
|
|
210
|
+
return { ok: realSpawnCalls - before === 1, counted: realSpawnCalls - before }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** 路径能否"打开文件夹":存在且是目录(文件也给开,交给文件管理器处理)。
|
|
214
|
+
* 返回 null 表示可以打开,否则是给用户看的原因(不抛)。 */
|
|
215
|
+
function openFolderPathIssue(target) {
|
|
216
|
+
try {
|
|
217
|
+
const st = statSync(target)
|
|
218
|
+
return st.isDirectory() || st.isFile() ? null : `${target} 既不是文件也不是目录`
|
|
219
|
+
} catch (error) {
|
|
220
|
+
const code = error?.code ?? ''
|
|
221
|
+
if (code === 'ENOENT') return `目录不存在:${target}(可能已被删除或改名,请刷新落地列表)`
|
|
222
|
+
return `读不到该路径:${target}(${error instanceof Error ? error.message : String(error)})`
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** 打开文件夹的**可判定**执行器(2026-10-13 改错,用户实测「点了没反应」):
|
|
227
|
+
* 旧实现是 `execFile('explorer.exe', [target], {detached:true}).unref()` —— **即发即忘**:
|
|
228
|
+
* ① 没有 error 监听(spawn 失败如 ENOENT 是**异步** 'error' 事件,try/catch 抓不到,
|
|
229
|
+
* 实测:`spawn('不存在的.exe')` 在 try 块内不抛,一个 tick 后才在 error 事件里出现);
|
|
230
|
+
* ② 立刻回 `{ok:true}` —— 失败也报成功(假绿),用户拿不到任何判据;
|
|
231
|
+
* ③ 客户端又把非 2xx 统一吞掉(`catch {}`)⇒ 用户那边**一个字都看不到**。
|
|
232
|
+
* 现在:启动后**等它的结局**再回话(对象只描述观察到的事实):
|
|
233
|
+
* · 'error'(ENOENT/EACCES…)→ ok:false + 系统原话(这就是"失败必须说出来"的正身);
|
|
234
|
+
* · 'exit' → ok:true,退出码与用时**如实带上**(真机 explorer.exe 把请求交给已有 shell 进程后
|
|
235
|
+
* 自己退出,退出码常是 1;这是委派而非失败,所以不判红,但也绝不谎称"窗口已打开");
|
|
236
|
+
* · 超时仍在跑 → ok:true 且注明"未在 Nms 内退出"。
|
|
237
|
+
* 判据(本仓口径):宁可红也不报假绿;静默一律不许。 */
|
|
238
|
+
function openFolderVerified({ target, bin = null, spawnFn = null, launchWaitMs = 1200, allowRealSpawn = false } = {}) {
|
|
239
|
+
const started = Date.now()
|
|
240
|
+
return new Promise((resolve) => {
|
|
241
|
+
// ② 保险丝:测试红线。**先于任何进程创建**判定,且如实说明被拦下的原因。
|
|
242
|
+
if (process.env.DSH_OPEN_FOLDER_NO_SPAWN === '1') {
|
|
243
|
+
resolve({
|
|
244
|
+
ok: false,
|
|
245
|
+
reason: 'spawn-guard',
|
|
246
|
+
durationMs: Date.now() - started,
|
|
247
|
+
error: '测试红线:本进程禁止真的启动文件管理器(DSH_OPEN_FOLDER_NO_SPAWN=1)',
|
|
248
|
+
})
|
|
249
|
+
return
|
|
250
|
+
}
|
|
251
|
+
// ③ **默认安全**(2026-10-13 第二次事故后收紧):回落到**底层真 spawn**(defaultSpawn)需要
|
|
252
|
+
// **显式 opt-in**;注入桩(spawnFn / __setOpenFolderSpawn)不在此列(调用方自担,且不建进程)。
|
|
253
|
+
// 为什么必须这样:旧形态是"默认真 spawn,靠外部设 DSH_OPEN_FOLDER_NO_SPAWN 才拦住"——
|
|
254
|
+
// 任何一条测试路径漏了注入(或忘了设那个环境变量)就会真的起 explorer.exe 指到夹具目录,
|
|
255
|
+
// 在用户桌面弹出「位置不可用」(真机第二次事故)。现在"漏了注入"的默认结局是**被拒绝**,
|
|
256
|
+
// 不是弹窗。生产的 routeRepoOpen 是本函数**唯一**的 opt-in 调用方(它代表"用户点了按钮")。
|
|
257
|
+
const injected = typeof spawnFn === 'function' ? spawnFn : injectedSpawn
|
|
258
|
+
if (injected === null && !realSpawnOptIn(allowRealSpawn)) {
|
|
259
|
+
resolve({
|
|
260
|
+
ok: false,
|
|
261
|
+
reason: 'spawn-guard',
|
|
262
|
+
durationMs: Date.now() - started,
|
|
263
|
+
error: '测试红线:本进程默认禁止真的启动文件管理器 —— 真 spawn 需显式 opt-in(openFolderVerified({allowRealSpawn:true}) 或 DSH_OPEN_FOLDER_ALLOW_REAL_SPAWN=1)',
|
|
264
|
+
})
|
|
265
|
+
return
|
|
266
|
+
}
|
|
267
|
+
const launch = injected ?? defaultSpawn
|
|
268
|
+
const command = typeof bin === 'string' && bin !== '' ? bin : openFolderBin()
|
|
269
|
+
let child = null
|
|
270
|
+
try {
|
|
271
|
+
// stdio:'ignore' —— 不建管道(旧 execFile 默认管道 + unref 会留下未 unref 的 pipe 句柄),
|
|
272
|
+
// detached:true 让文件管理器窗口在插件宿主退出后依然在。
|
|
273
|
+
child = launch(command, [target], { windowsHide: true, detached: true, stdio: 'ignore' })
|
|
274
|
+
} catch (error) {
|
|
275
|
+
resolve({ ok: false, reason: 'spawn-throw', durationMs: Date.now() - started, error: error instanceof Error ? error.message : String(error) })
|
|
276
|
+
return
|
|
277
|
+
}
|
|
278
|
+
let done = false
|
|
279
|
+
const finish = (payload) => {
|
|
280
|
+
if (done) return
|
|
281
|
+
done = true
|
|
282
|
+
clearTimeout(timer)
|
|
283
|
+
resolve({ durationMs: Date.now() - started, ...payload })
|
|
284
|
+
}
|
|
285
|
+
const timer = setTimeout(() => finish({
|
|
286
|
+
ok: true,
|
|
287
|
+
reason: 'launched',
|
|
288
|
+
exited: false,
|
|
289
|
+
note: `已向 ${command} 发出打开请求,进程在 ${launchWaitMs}ms 内未退出(窗口通常已弹出)`,
|
|
290
|
+
}), launchWaitMs)
|
|
291
|
+
child.on('error', (error) => finish({
|
|
292
|
+
ok: false,
|
|
293
|
+
reason: 'spawn-error',
|
|
294
|
+
code: error?.code ?? null,
|
|
295
|
+
error: error instanceof Error ? error.message : String(error),
|
|
296
|
+
}))
|
|
297
|
+
child.on('exit', (code, signal) => finish({
|
|
298
|
+
ok: true,
|
|
299
|
+
reason: 'exited',
|
|
300
|
+
code,
|
|
301
|
+
note: `${command} 进程已退出(exit ${code ?? '?'}${signal ? `/${signal}` : ''},用时 ${Date.now() - started}ms)—— 请求已交给系统文件管理器`,
|
|
302
|
+
}))
|
|
303
|
+
try { child.unref?.() } catch {}
|
|
304
|
+
})
|
|
305
|
+
}
|
|
306
|
+
|
|
154
307
|
async function routeRepoOpen(req, res, rc) {
|
|
155
308
|
const ctx = rc.ctx
|
|
156
309
|
const url = rc.url
|
|
@@ -161,19 +314,41 @@ async function routeRepoOpen(req, res, rc) {
|
|
|
161
314
|
const rootNorm = getReposDir().replace(/[\\/]+$/u, '')
|
|
162
315
|
const tNorm = target.replace(/[\\/]+$/u, '')
|
|
163
316
|
if (!tNorm.startsWith(rootNorm) || tNorm === rootNorm) {
|
|
164
|
-
|
|
317
|
+
// 守卫照旧(只允许落地目录内的路径),但**必须说清为什么被拒** ——
|
|
318
|
+
// 旧实现只回一句 400,客户端又把它吞了 ⇒ 用户看到的就是"点了没反应"。
|
|
319
|
+
sendError(res, 400, `为安全起见只能打开仓库落地目录内的路径:${target || '(空路径)'} 不在 ${getReposDir()} 内`)
|
|
165
320
|
return
|
|
166
321
|
}
|
|
167
322
|
if (!existsSync(target)) {
|
|
168
|
-
sendError(res, 404, `目录不存在:${target}
|
|
323
|
+
sendError(res, 404, `目录不存在:${target}(可能已被删除或改名,请刷新落地列表)`)
|
|
169
324
|
return
|
|
170
325
|
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
326
|
+
const issue = openFolderPathIssue(target)
|
|
327
|
+
if (issue !== null) {
|
|
328
|
+
sendError(res, 400, `无法打开:${issue}`)
|
|
329
|
+
return
|
|
330
|
+
}
|
|
331
|
+
// 启动点是注入缝(单测经 rc.deps.openFolderLauncher 打桩,**绝不真的起文件管理器**);
|
|
332
|
+
// 生产 routeDeps 不带这个键 ⇒ 走 openFolderVerified 的实现。
|
|
333
|
+
const launch = typeof rc.deps?.openFolderLauncher === 'function' ? rc.deps.openFolderLauncher : openFolderVerified
|
|
334
|
+
// 生产唯一的 opt-in 调用方:用户点了「打开文件夹」按钮,才允许回落到真 spawn。
|
|
335
|
+
const result = await launch({ target, bin: openFolderBin(), allowRealSpawn: true })
|
|
336
|
+
if (result.ok !== true) {
|
|
337
|
+
sendError(res, 500, `打开文件夹失败:${result.error ?? result.reason}(命令:${openFolderBin()} ${target})`)
|
|
338
|
+
return
|
|
176
339
|
}
|
|
340
|
+
// 如实回结构化状态:成功也带证据(用时/退出码/note),前端据此显示结果而不是静默。
|
|
341
|
+
sendJson(res, 200, {
|
|
342
|
+
ok: true,
|
|
343
|
+
path: target,
|
|
344
|
+
target,
|
|
345
|
+
action: 'open-folder',
|
|
346
|
+
via: openFolderBin(),
|
|
347
|
+
reason: result.reason,
|
|
348
|
+
durationMs: result.durationMs ?? null,
|
|
349
|
+
exitCode: typeof result.code === 'number' ? result.code : null,
|
|
350
|
+
note: typeof result.note === 'string' ? result.note : '',
|
|
351
|
+
})
|
|
177
352
|
return
|
|
178
353
|
}
|
|
179
354
|
|
|
@@ -258,4 +433,4 @@ async function routeComponentStatus(req, res, rc) {
|
|
|
258
433
|
return
|
|
259
434
|
}
|
|
260
435
|
|
|
261
|
-
export { routeComponents, routeRepoClone, routeRepoList, routeRepoLandConfig, routeRepoRemove, routeRepoOpen, routeComponentAutostart, routeComponentStart, routeComponentStop, routeComponentStatus }
|
|
436
|
+
export { routeComponents, routeRepoClone, routeRepoList, routeRepoLandConfig, routeRepoRemove, routeRepoOpen, routeComponentAutostart, routeComponentStart, routeComponentStop, routeComponentStatus, openFolderBin, openFolderVerified, __setOpenFolderSpawn, __openFolderRealSpawnCalls, __openFolderSelfCheck }
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// L2 · routes —— 框架(/framework-check · /check-update · /framework-upgrade · /framework-rollback · /framework-upgrade-status · /framework-relaunch · /compat-gate · /restart)
|
|
2
2
|
// 分层 Step 8b:从 lib/index.js 的 handle() 原样搬出(只搬移未改逻辑;缩进保持原样)
|
|
3
3
|
|
|
4
|
-
import { readFileSync, writeFileSync, existsSync, statSync, mkdirSync } from 'node:fs'
|
|
4
|
+
import { readFileSync, writeFileSync, existsSync, statSync, mkdirSync, rmSync } from 'node:fs'
|
|
5
5
|
import { writeFile } from 'node:fs/promises'
|
|
6
6
|
import { execFile } from 'node:child_process'
|
|
7
|
-
import { dirname, join, resolve } from 'node:path'
|
|
7
|
+
import { dirname, join, resolve, basename } from 'node:path'
|
|
8
8
|
import { tmpdir } from 'node:os'
|
|
9
9
|
import { createRequire } from 'node:module'
|
|
10
10
|
import { readCompatGate, writeCompatGate } from '../domain/compat.js'
|
|
@@ -460,14 +460,56 @@ async function routeRestart(req, res, rc) {
|
|
|
460
460
|
}
|
|
461
461
|
|
|
462
462
|
/** 清掉「最近一次升级/回滚记录」(2026-09-25 用户要求):记录常驻是设计,但用户要能一键关掉。
|
|
463
|
-
* 只删状态文件与心跳(不动日志,日志仍可在 ~/.dsh/plugin-console 里查)。
|
|
463
|
+
* 只删状态文件与心跳(不动日志,日志仍可在 ~/.dsh/plugin-console 里查)。
|
|
464
|
+
* 2026-10-11 改错:删除失败**如实上报**(500 + 每个文件的原因 + removed/failed 明细),
|
|
465
|
+
* 不再像旧实现那样吞掉异常还回 ok:true —— 静默失败一律不许(见函数内注释与
|
|
466
|
+
* tests/test-framework-status-clear.mjs 的正控/负控)。 */
|
|
467
|
+
/** 删文件失败时给用户看的一句话原因(code + 系统原文,便于判因)。 */
|
|
468
|
+
function explainRemoveError(error) {
|
|
469
|
+
const code = typeof error?.code === 'string' && error.code !== '' ? error.code : 'UNKNOWN'
|
|
470
|
+
const text = (error instanceof Error ? error.message : String(error ?? '')).split('\n')[0].trim()
|
|
471
|
+
return text === '' ? code : `${code}: ${text}`
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** 删除时文件已不在 = 目标达成(不计失败);其余错误一律算失败。 */
|
|
475
|
+
function removeTargetGone(error) {
|
|
476
|
+
return error?.code === 'ENOENT'
|
|
477
|
+
}
|
|
478
|
+
|
|
464
479
|
async function routeFrameworkStatusClear(req, res, rc) {
|
|
465
480
|
const dir = join(dshHome(), 'plugin-console')
|
|
466
481
|
const state = join(dir, 'fw-upgrade-state.txt')
|
|
482
|
+
const io = rc.deps?.fs ?? { existsSync, rmSync } // 注入缝:负控要能造出真实的删除失败
|
|
467
483
|
const removed = []
|
|
484
|
+
const failed = []
|
|
468
485
|
for (const f of [state, `${state}.hb`]) {
|
|
469
|
-
|
|
486
|
+
let present = false
|
|
487
|
+
try { present = io.existsSync(f) === true } catch (error) { failed.push({ file: basename(f), reason: explainRemoveError(error) }); continue }
|
|
488
|
+
if (!present) continue
|
|
489
|
+
try {
|
|
490
|
+
io.rmSync(f, { force: true })
|
|
491
|
+
removed.push(basename(f))
|
|
492
|
+
} catch (error) {
|
|
493
|
+
// 2026-10-11 改错:这里原来是 `catch {}` + 无条件 ok:true —— 静默失败。
|
|
494
|
+
// 先前的真实现状更糟:rmSync/basename 没 import ⇒ 每轮都抛 ReferenceError、被空 catch 吞掉,
|
|
495
|
+
// 路由照样回 200 {"ok":true,"removed":[]},标记文件一个都没删(用户以为清掉了)。
|
|
496
|
+
// 判据(用户红线):宁可红,也不报假的绿 —— 删除失败必须如实上报原因。
|
|
497
|
+
if (removeTargetGone(error)) { removed.push(basename(f)); continue }
|
|
498
|
+
failed.push({ file: basename(f), reason: explainRemoveError(error) })
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
if (failed.length > 0) {
|
|
502
|
+
// 如实上报:500 + 每个文件的原因 + 与成功路径同形的 removed/status 字段(客户端不必猜)。
|
|
503
|
+
sendJson(res, 500, {
|
|
504
|
+
ok: false,
|
|
505
|
+
error: `清除升级记录失败:${failed.map((x) => `${x.file}(${x.reason})`).join('、')}`,
|
|
506
|
+
removed,
|
|
507
|
+
failed,
|
|
508
|
+
status: 'clear-failed',
|
|
509
|
+
message: null,
|
|
510
|
+
})
|
|
511
|
+
return
|
|
470
512
|
}
|
|
471
|
-
sendJson(res, 200, { ok: true, removed, status: 'idle', message: null })
|
|
513
|
+
sendJson(res, 200, { ok: true, removed, failed, status: 'idle', message: null })
|
|
472
514
|
}
|
|
473
515
|
export { refuseWhenShellHosted, refuseWhenPlatformUnsupported, routeFrameworkStatusClear, routeFrameworkUpgradeStatusGet, routeFrameworkRelaunch, routeFrameworkCheck, routeCompatGate, routeCheckUpdate, routeFrameworkRollback, routeRestart }
|
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.46",
|
|
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",
|