@noob-stupid/dsh-plugin-console 0.5.2 → 0.5.4
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/README.md
CHANGED
|
@@ -154,6 +154,29 @@ Requires: DSH ≥ 0.1.0-rc.6 (web profile, with `dsh-client-modules` / `dsh-host
|
|
|
154
154
|
|
|
155
155
|
---
|
|
156
156
|
|
|
157
|
+
## Screenshots · 界面一览
|
|
158
|
+
|
|
159
|
+
> 入口挂在**官方插件管理页自己的插槽**(`plugins.bundle.config`,key = 包名):装完之后它出现在
|
|
160
|
+
> 「设置 → 插件 → 已安装」里我们这一条下面,用的是官方容器与官方风格 —— 不是 DOM 注入,官方更新也不怕。
|
|
161
|
+
|
|
162
|
+
**1 · 已安装列表里的入口**(官方页,点开我们这条即见)
|
|
163
|
+
|
|
164
|
+

|
|
165
|
+
|
|
166
|
+
**2 · 「升级安全」面板**(就地展开:运行模式 / 框架版本 / 回滚点 / 环境指纹 / 补声明 / 契约预检 / 各功能入口)
|
|
167
|
+
|
|
168
|
+

|
|
169
|
+
|
|
170
|
+
**3 · 框架升级 / 回滚**(可选目标版本、常驻的升级进度、一键回滚上一版)
|
|
171
|
+
|
|
172
|
+

|
|
173
|
+
|
|
174
|
+
**4 · 门控明细**(谁被隔离、为什么:升级预扫 or 启动失败隔离,附来源与时间)
|
|
175
|
+
|
|
176
|
+

|
|
177
|
+
|
|
178
|
+
---
|
|
179
|
+
|
|
157
180
|
## Features
|
|
158
181
|
|
|
159
182
|
### Installed plugins (one-click toggle + details)
|
package/lib/client.js
CHANGED
|
@@ -4962,14 +4962,19 @@ setMessage(t("failed") + ":" + (data?.reason ?? "未知"));
|
|
|
4962
4962
|
control === undefined || control === null ? null : control);
|
|
4963
4963
|
const sw = (on, onClick, title) => el("button", { type: "button", title: title, onClick: onClick, disabled: busy, style: { width: "40px", height: "22px", borderRadius: "999px", border: "none", padding: 0, cursor: busy ? "default" : "pointer", background: on ? "var(--dsw-alias-bg-accent, #4d6bfe)" : "var(--dsw-alias-bg-secondary, rgba(127,127,127,.35))", position: "relative", flex: "0 0 auto", marginTop: "2px" } },
|
|
4964
4964
|
el("span", { style: { position: "absolute", top: "3px", left: on ? "21px" : "3px", width: "16px", height: "16px", borderRadius: "50%", background: "#fff", transition: "left .18s cubic-bezier(.34,1.56,.64,1)" } }));
|
|
4965
|
-
const jump = (id, label) => el("button", { key: id, type: "button", className: styles.toggle, style: { marginRight: "8px", marginBottom: "8px" }, onClick: () => setSection(id) }, label);
|
|
4965
|
+
const jump = (id, label) => el("button", { key: id, type: "button", className: styles.toggle, style: { marginRight: "8px", marginBottom: "8px" }, onClick: () => setSection((prev) => (prev === id ? null : id)) }, label);
|
|
4966
4966
|
const mode = status !== null && status.mode === "observer" ? "observer" : status !== null && status.mode === "managed" ? "managed" : "observer";
|
|
4967
4967
|
const fp = status !== null && status.fingerprint !== null && status.fingerprint !== undefined ? status.fingerprint : {};
|
|
4968
4968
|
const rb = status !== null && status.rollback !== null && status.rollback !== undefined ? status.rollback : {};
|
|
4969
4969
|
const fw = status !== null && typeof status.frameworkVersion === "string" && status.frameworkVersion !== "" ? status.frameworkVersion : t("safetyUnknown");
|
|
4970
4970
|
const pending = backfill !== null && typeof backfill.count === "number" ? backfill.count : 0;
|
|
4971
4971
|
// 触发条:默认只看到这一行(点开在**原地**展开,功能包那种下拉,不走右侧固定抽屉)
|
|
4972
|
-
|
|
4972
|
+
// 收起/展开:收起时**清掉就地分区**,否则再展开时内嵌控制台会重新挂载并把上次那个面板自动打开一遍
|
|
4973
|
+
// (用户实测:点过「框架升级/回滚」→ 关掉弹窗 → 收起 → 再展开,弹窗又自己弹出来了)
|
|
4974
|
+
const toggleOpen = () => {
|
|
4975
|
+
if (open) { setOpen(false); setSection(null); } else { setOpen(true); }
|
|
4976
|
+
};
|
|
4977
|
+
const trigger = el("button", { type: "button", "aria-expanded": open ? "true" : "false", onClick: toggleOpen, style: { width: "100%", display: "flex", alignItems: "center", gap: "10px", padding: "12px 14px", borderRadius: "12px", border: "1px solid var(--dsw-alias-border-secondary, rgba(127,127,127,.18))", background: "var(--dsw-alias-bg-layer-4, rgba(127,127,127,.10))", cursor: "pointer", textAlign: "left", color: "inherit" } },
|
|
4973
4978
|
icon("shield"),
|
|
4974
4979
|
el("div", { style: { flex: "1 1 auto", minWidth: "0" } },
|
|
4975
4980
|
el("div", { style: { fontSize: "13px", fontWeight: 500 } }, t("safetyTab")),
|
|
@@ -7,8 +7,9 @@ import { readFile } from 'node:fs/promises'
|
|
|
7
7
|
import { basename, dirname, join, resolve } from 'node:path'
|
|
8
8
|
import { aiRepair } from './ai-run.js'
|
|
9
9
|
import { maybeAutoAdaptCompat } from './compat.js'
|
|
10
|
-
import { DEFAULT_BUNDLES, AI_CONSENT_TIMEOUT_MS, addBundleToManifest, backfillMissingDeps, curlManualInstall,
|
|
10
|
+
import { DEFAULT_BUNDLES, AI_CONSENT_TIMEOUT_MS, addBundleToManifest, backfillMissingDeps, curlManualInstall, detectBundleOnly, ensureBundlePatchIntegrity, githubReleaseInstall, pnpmInstall, raceInstallChannels, readBundlePatchRefNames, readGithubAuth, syncAggregateSubpackageVersions } from './install.js'
|
|
11
11
|
import { fetchRepoPackage, fetchRepoPackageEx, fetchSubpackageNames, packageProbeErrorText, subpackageCandidates } from './market.js'
|
|
12
|
+
import { declareProfileDependency } from './manifest.js'
|
|
12
13
|
import { appendInsert, readPatchState, syncNameFromNote } from './patch.js'
|
|
13
14
|
import { deriveEntryId, listEntries } from './runtime.js'
|
|
14
15
|
import { detectSkillRepo, runSkillInstallJob } from './skills.js'
|
|
@@ -490,10 +491,13 @@ async function runInstallJob(job, ports) {
|
|
|
490
491
|
} else {
|
|
491
492
|
job.bundleNote = (job.bundleNote ? job.bundleNote + ';' : '') + `${installedName} 已由已安装聚合包提供,仅更新包本身,不再注册重复行`
|
|
492
493
|
}
|
|
493
|
-
//
|
|
494
|
-
//
|
|
494
|
+
// 安装即声明(2026-09-24):装到哪版就把它写进 profile 清单的 dependencies,
|
|
495
|
+
// **并立刻同步 pnpm-lock.yaml**(declareProfileDependency 内部已含对账)——只写清单不写 lock
|
|
496
|
+
// 会让之后任何 pnpm 操作硬失败(ERR_PNPM_CANNOT_REMOVE_MISSING_DEPS)。
|
|
497
|
+
// 对账失败只记 note,不让已经装好的包变成"安装失败"。
|
|
495
498
|
const declaredBundle = await declareProfileDependency(profileDir, installedName)
|
|
496
499
|
if (declaredBundle.changed) job.declared = declaredBundle.version
|
|
500
|
+
if (declaredBundle.lockSynced === false && declaredBundle.lockNote !== null) job.lockNote = declaredBundle.lockNote
|
|
497
501
|
job.status = 'done'
|
|
498
502
|
return
|
|
499
503
|
}
|
|
@@ -501,15 +505,16 @@ async function runInstallJob(job, ports) {
|
|
|
501
505
|
job.note = `${installedName} 已由已安装条目提供,仅更新包本身,不重复注册`
|
|
502
506
|
const declaredServed = await declareProfileDependency(profileDir, installedName)
|
|
503
507
|
if (declaredServed.changed) job.declared = declaredServed.version
|
|
508
|
+
if (declaredServed.lockSynced === false && declaredServed.lockNote !== null) job.lockNote = declaredServed.lockNote
|
|
504
509
|
job.status = 'done'
|
|
505
510
|
return
|
|
506
511
|
}
|
|
507
512
|
const entryId = deriveEntryId(installedName, taken)
|
|
508
513
|
await appendInsert(patchPath, entryId, installedName)
|
|
509
514
|
job.entryId = entryId
|
|
510
|
-
// 安装即声明(同上):普通插件装完把依赖写进 profile 清单
|
|
511
515
|
const declared = await declareProfileDependency(profileDir, installedName)
|
|
512
516
|
if (declared.changed) job.declared = declared.version
|
|
517
|
+
if (declared.lockSynced === false && declared.lockNote !== null) job.lockNote = declared.lockNote
|
|
513
518
|
job.status = 'done'
|
|
514
519
|
} catch (error) {
|
|
515
520
|
job.status = 'failed'
|
|
@@ -6,7 +6,6 @@ import { readFile, writeFile } from 'node:fs/promises'
|
|
|
6
6
|
import { execFile } from 'node:child_process'
|
|
7
7
|
import { dirname, join, resolve } from 'node:path'
|
|
8
8
|
import { tmpdir } from 'node:os'
|
|
9
|
-
import { DEFAULT_BUNDLES, addBundleToManifest, declareProfileDependency, removeBundleFromManifest, undeclareProfileDependency } from './manifest.js'
|
|
10
9
|
import { sanitizePatchText } from './patch.js'
|
|
11
10
|
import { orderedRegistries, readSources } from './sources.js'
|
|
12
11
|
import { execFileAsync, runPnpmWithFallback } from '../infra/exec.js'
|
|
@@ -30,6 +29,23 @@ async function detectBundleOnly(profileDir, packageName) {
|
|
|
30
29
|
}
|
|
31
30
|
}
|
|
32
31
|
|
|
32
|
+
/** 把包追加进 profile 的 dsh.profile.bundles 层(与官方 dsh plugin add 的 reconcile 一致)。 */
|
|
33
|
+
async function addBundleToManifest(profileDir, packageName) {
|
|
34
|
+
return queuedWrite(async () => {
|
|
35
|
+
const manifestPath = join(profileDir, 'package.json')
|
|
36
|
+
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
|
|
37
|
+
const bundles = manifest.dsh?.profile?.bundles ?? []
|
|
38
|
+
if (!bundles.includes(packageName)) {
|
|
39
|
+
bundles.push(packageName)
|
|
40
|
+
manifest.dsh = { ...(manifest.dsh ?? {}), profile: { ...(manifest.dsh?.profile ?? {}), bundles } }
|
|
41
|
+
await writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf8')
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 官方 profile 模板自带的 bundle(其余 bundle 视为用户额外添加)。 */
|
|
47
|
+
const DEFAULT_BUNDLES = ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app']
|
|
48
|
+
|
|
33
49
|
/** 读取用户额外 bundle 的插入行归属表:行 id / 包名 → 所属 bundle 包名。 */
|
|
34
50
|
async function readExtraBundleOwners(profileDir) {
|
|
35
51
|
const owners = new Map()
|
|
@@ -64,6 +80,20 @@ async function readExtraBundleOwners(profileDir) {
|
|
|
64
80
|
return owners
|
|
65
81
|
}
|
|
66
82
|
|
|
83
|
+
/** 从 profile manifest 移除一个 bundle。 */
|
|
84
|
+
async function removeBundleFromManifest(profileDir, bundlePkg) {
|
|
85
|
+
return queuedWrite(async () => {
|
|
86
|
+
const manifestPath = join(profileDir, 'package.json')
|
|
87
|
+
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
|
|
88
|
+
const bundles = manifest.dsh?.profile?.bundles ?? []
|
|
89
|
+
const next = bundles.filter((name) => name !== bundlePkg)
|
|
90
|
+
if (next.length !== bundles.length) {
|
|
91
|
+
manifest.dsh = { ...(manifest.dsh ?? {}), profile: { ...(manifest.dsh?.profile ?? {}), bundles: next } }
|
|
92
|
+
await writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf8')
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
|
|
67
97
|
/**
|
|
68
98
|
* 读取 dsh-github-login(独立登录工具)写入的 GitHub 令牌文件。
|
|
69
99
|
* 只对外暴露登录状态(login),绝不下发令牌本身。
|
|
@@ -566,4 +596,4 @@ async function syncAggregateSubpackageVersions(profileDir, packageName, registri
|
|
|
566
596
|
return updated
|
|
567
597
|
}
|
|
568
598
|
|
|
569
|
-
export { AI_CONSENT_TIMEOUT_MS, DEFAULT_BUNDLES, detectBundleOnly, addBundleToManifest, removeBundleFromManifest,
|
|
599
|
+
export { AI_CONSENT_TIMEOUT_MS, DEFAULT_BUNDLES, detectBundleOnly, addBundleToManifest, removeBundleFromManifest, readExtraBundleOwners, readGithubAuth, pnpmInstall, curlManualInstall, raceInstallChannels, verifyPackageBox, parseBundlePatchRefs, backfillMissingDeps, githubReleaseInstall, readBundlePatchRefNames, ensureBundlePatchIntegrity, syncAggregateSubpackageVersions, installJobView }
|
|
@@ -14,6 +14,7 @@ import { readFile, writeFile } from 'node:fs/promises'
|
|
|
14
14
|
import { join } from 'node:path'
|
|
15
15
|
import { queuedWrite } from '../infra/fsx.js'
|
|
16
16
|
import { resolvePackageJson } from '../infra/paths.js'
|
|
17
|
+
import { reconcileLockfile } from './selfupdate.js'
|
|
17
18
|
|
|
18
19
|
/** 官方 profile 模板自带的 bundle(其余 bundle 视为用户额外添加)。 */
|
|
19
20
|
const DEFAULT_BUNDLES = ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app']
|
|
@@ -88,20 +89,36 @@ async function removeBundleFromManifest(profileDir, bundlePkg) {
|
|
|
88
89
|
* · 官方「设置 → 插件」的**已安装**分区只显示清单里声明过的包 —— 我们手铺的包在那里看不见;
|
|
89
90
|
* · 任何一次 pnpm 操作都会按清单 + lock 重装,未声明的包会被**还原/清掉**;
|
|
90
91
|
* · 连我们自己的「清理残余」也按「有没有被声明」判孤儿 —— 判据病根也在这里。
|
|
91
|
-
* @returns {{ changed: boolean, version: string|null, reason: string|null }}
|
|
92
|
+
* @returns {{ changed: boolean, version: string|null, reason: string|null, lockSynced: boolean, lockNote: string|null }}
|
|
92
93
|
*/
|
|
93
|
-
async function declareProfileDependency(profileDir, packageName, version = null) {
|
|
94
|
-
|
|
94
|
+
async function declareProfileDependency(profileDir, packageName, version = null, { syncLock = true } = {}) {
|
|
95
|
+
const result = await queuedWrite(async () => {
|
|
95
96
|
const manifest = await readManifest(profileDir)
|
|
96
97
|
const resolved = typeof version === 'string' && version !== '' ? version : await installedVersionOf(profileDir, packageName)
|
|
97
|
-
if (resolved === null) return { changed: false, version: null, reason: '读不到已安装版本' }
|
|
98
|
+
if (resolved === null) return { changed: false, version: null, reason: '读不到已安装版本', needsLockSync: false }
|
|
98
99
|
const deps = { ...(manifest.dependencies ?? {}) }
|
|
99
|
-
if (deps[packageName] === resolved) return { changed: false, version: resolved, reason: null }
|
|
100
|
+
if (deps[packageName] === resolved) return { changed: false, version: resolved, reason: null, needsLockSync: false }
|
|
100
101
|
deps[packageName] = resolved
|
|
101
102
|
manifest.dependencies = deps
|
|
102
103
|
await writeManifest(profileDir, manifest)
|
|
103
|
-
return { changed: true, version: resolved, reason: null }
|
|
104
|
+
return { changed: true, version: resolved, reason: null, needsLockSync: true }
|
|
104
105
|
})
|
|
106
|
+
if (result.needsLockSync !== true || syncLock !== true) return { ...result, lockSynced: false, lockNote: null }
|
|
107
|
+
// **写完清单必须把 lock 拉齐**(2026-09-24 CI 实测):只写清单不写 lock → 清单/lock 不一致 →
|
|
108
|
+
// 之后任何 pnpm 操作都会硬失败(ERR_PNPM_CANNOT_REMOVE_MISSING_DEPS)。
|
|
109
|
+
// 放在写队列**之外**执行(对账本身会跑 pnpm,不该占着清单写锁);失败只记 note,不让安装失败。
|
|
110
|
+
return { ...(await syncLockFor(profileDir, [packageName])), changed: true, version: result.version, reason: null }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** 声明之后同步 lock:用仓库既有的对账通道;失败如实回报,不假装成功。 */
|
|
114
|
+
async function syncLockFor(profileDir, names) {
|
|
115
|
+
try {
|
|
116
|
+
const { reconcileLockfile } = await import('./selfupdate.js')
|
|
117
|
+
await reconcileLockfile({ profileDir, packages: names.map((name) => ({ name })) })
|
|
118
|
+
return { lockSynced: true, lockNote: null }
|
|
119
|
+
} catch (error) {
|
|
120
|
+
return { lockSynced: false, lockNote: `lock 对账失败:${error instanceof Error ? error.message : String(error)}` }
|
|
121
|
+
}
|
|
105
122
|
}
|
|
106
123
|
|
|
107
124
|
/** 卸载即撤销声明。 */
|
|
@@ -162,5 +179,5 @@ async function applyDependencyBackfill(profileDir) {
|
|
|
162
179
|
|
|
163
180
|
export {
|
|
164
181
|
DEFAULT_BUNDLES, addBundleToManifest, applyDependencyBackfill, declareProfileDependency,
|
|
165
|
-
installedVersionOf, planDependencyBackfill, removeBundleFromManifest, undeclareProfileDependency,
|
|
182
|
+
installedVersionOf, planDependencyBackfill, removeBundleFromManifest, syncLockFor, undeclareProfileDependency,
|
|
166
183
|
}
|
package/lib/server/infra/exec.js
CHANGED
|
@@ -47,9 +47,19 @@ function resolvePnpmRunners({ platform = process.platform, execPath = process.ex
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
/** 依次尝试各执行方式;只有"执行方式本身不可用"(ENOENT / MODULE_NOT_FOUND)才换下一个,
|
|
50
|
-
* 真正的安装失败(网络、依赖冲突等)立即抛出,并附上已尝试的清单便于排查。
|
|
50
|
+
* 真正的安装失败(网络、依赖冲突等)立即抛出,并附上已尝试的清单便于排查。
|
|
51
|
+
*
|
|
52
|
+
* 2026-09-24:抛错时必须**带上 pnpm 的原始 stderr** —— 之前只留 `Command failed: <命令行>`,
|
|
53
|
+
* 界面与测试里都看不到真实原因(CI 的「真装真卸冒烟」就是这么红的:只知道失败、不知道为什么)。 */
|
|
51
54
|
async function runPnpmWithFallback(args, { execOpts = {}, runners = resolvePnpmRunners() } = {}) {
|
|
52
55
|
let lastError = null
|
|
56
|
+
const withStderr = (error) => {
|
|
57
|
+
const stderr = typeof error?.stderr === 'string' ? error.stderr.trim() : ''
|
|
58
|
+
const stdout = typeof error?.stdout === 'string' ? error.stdout.trim() : ''
|
|
59
|
+
if (stderr === '' && stdout === '') return error
|
|
60
|
+
const detail = [stderr, stdout].filter((s) => s !== '').join(' / ').split(/\r?\n/u).slice(-6).join(' ⏎ ').slice(0, 800)
|
|
61
|
+
return new Error(`${String(error?.message ?? 'pnpm 执行失败')}|真实输出:${detail}`)
|
|
62
|
+
}
|
|
53
63
|
for (const runner of runners) {
|
|
54
64
|
const { bin, argv } = runner.run(args)
|
|
55
65
|
try {
|
|
@@ -57,9 +67,9 @@ async function runPnpmWithFallback(args, { execOpts = {}, runners = resolvePnpmR
|
|
|
57
67
|
await execFileAsync(bin, argv, execOpts)
|
|
58
68
|
return { runner }
|
|
59
69
|
} catch (error) {
|
|
60
|
-
lastError = error
|
|
70
|
+
lastError = withStderr(error)
|
|
61
71
|
const message = String(error?.message ?? '')
|
|
62
|
-
if (!/ENOENT|Cannot find module/u.test(message)) throw
|
|
72
|
+
if (!/ENOENT|Cannot find module/u.test(message)) throw lastError
|
|
63
73
|
}
|
|
64
74
|
}
|
|
65
75
|
const tried = runners.map((r) => r.note).join(' → ')
|
|
@@ -15,6 +15,7 @@ import { dirname } from 'node:path'
|
|
|
15
15
|
import { compareFingerprint, computeFingerprint, readCompatMode, readFingerprint, readRollbackRecord, stampFingerprint, writeCompatMode } from '../domain/compat-state.js'
|
|
16
16
|
import { currentFrameworkVersion } from '../domain/framework.js'
|
|
17
17
|
import { applyDependencyBackfill, planDependencyBackfill } from '../domain/manifest.js'
|
|
18
|
+
import { reconcileLockfile } from '../domain/selfupdate.js'
|
|
18
19
|
import { sendError, sendJson } from '../infra/httpd.js'
|
|
19
20
|
import { baseDirOf, profileDirOf, resolvePackageJson } from '../infra/paths.js'
|
|
20
21
|
import { existsSync } from 'node:fs'
|
|
@@ -93,11 +94,15 @@ async function routeDeclareInstalled(req, res, rc) {
|
|
|
93
94
|
try {
|
|
94
95
|
if (mode === 'apply') {
|
|
95
96
|
const result = await applyDependencyBackfill(profileDir)
|
|
97
|
+
// 关键:只写清单不写 lock → 之后任何 pnpm 操作都会因清单/lock 不一致失败(2026-09-24 CI 实测)
|
|
98
|
+
let lockNote = null
|
|
99
|
+
try { await reconcileLockfile({ profileDir }) } catch (error) { lockNote = `lock 对账失败:${error instanceof Error ? error.message : String(error)}` }
|
|
96
100
|
sendJson(res, 200, {
|
|
97
101
|
ok: true,
|
|
98
102
|
mode,
|
|
99
103
|
declared: result.declared,
|
|
100
104
|
skipped: result.skipped,
|
|
105
|
+
lockNote,
|
|
101
106
|
note: result.declared.length === 0
|
|
102
107
|
? '没有需要补声明的插件'
|
|
103
108
|
: `已补声明 ${result.declared.length} 项 —— 它们现在会出现在官方/第三方插件页的「已安装」里,也不会再被 pnpm 还原`,
|
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.4",
|
|
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",
|