dsh-m 0.2.14 → 0.4.0
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.en.md +22 -9
- package/README.md +22 -9
- package/docs/DESIGN.md +34 -1
- package/lib/cli.js +41 -8
- package/lib/client.js +317 -15
- package/lib/core/build-approval.js +88 -0
- package/lib/core/compat-check.js +56 -0
- package/lib/core/dsh-cli.js +34 -7
- package/lib/core/enablement.js +92 -0
- package/lib/core/host-api.js +74 -6
- package/lib/core/installed.js +9 -0
- package/lib/core/market.js +100 -8
- package/lib/core/patch-yaml.js +143 -0
- package/lib/core/profile-transaction.js +30 -2
- package/lib/core/registry-probe.js +82 -0
- package/lib/core/registry.js +32 -1
- package/lib/core/settings-compat.js +180 -0
- package/lib/core/toggle.js +220 -0
- package/lib/core/versions.js +18 -5
- package/lib/host.js +92 -15
- package/lib/tools.js +69 -5
- package/package.json +7 -1
- package/registry.json +169 -19
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 开关写路径编排(0.4.0,plan Task 5;ADR-0001 委派降级)。
|
|
3
|
+
*
|
|
4
|
+
* 判定顺序(Rev2.1 定稿):
|
|
5
|
+
* ① 保护门最先(纯名单检查,无需枚举已装清单——空 profile 下 CLI 冒烟也得 protected);
|
|
6
|
+
* ② 不在已装清单 → not-installed;
|
|
7
|
+
* ③ 粒度路由:单 insert 行插件 → 行覆盖;多行/不可解析 → Bundle 选择;
|
|
8
|
+
* ④ 委派优先:`getService()` 探测官方 pluginManager(运行时探测,不判版本号),
|
|
9
|
+
* 官方 readOnlyReason 映射:'management-required'→protected、'unaddressable'→unaddressable;
|
|
10
|
+
* applied 以复读为准(官方富结果 application 字段鸭子读兜底);
|
|
11
|
+
* ⑤ 降级:行级 = 锁内保注释 YAML 编辑 + `setLivePluginDisabled(pkg, !enabled)` 活体
|
|
12
|
+
* (注意 live-plugin.ts 第二参是 disabled,方向取反);bundle 级 = 锁内 bundles 数组
|
|
13
|
+
* 编辑,恒 restart-required。
|
|
14
|
+
*
|
|
15
|
+
* 锁:与官方同款 `withFileLock(<profile>/package.json)`(dsh-atomic-write,锁文件
|
|
16
|
+
* `package.json.lock`、wx 独占)——防与官方 UI 并发编辑 cordis.patch.yml 丢更新。
|
|
17
|
+
* 开关不经 Profile 变更事务(grilling Q1):单文件轻量编辑、幂等可逆。
|
|
18
|
+
*/
|
|
19
|
+
import { readFile as fsReadFile } from 'node:fs/promises';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write';
|
|
22
|
+
import { listInstalledPlugins } from './installed.js';
|
|
23
|
+
import { isSingleRowPlugin, planRowOverride } from './patch-yaml.js';
|
|
24
|
+
import { PROTECTED_MODULES } from './enablement.js';
|
|
25
|
+
import { setLivePluginDisabled, loaderHost } from './live-plugin.js';
|
|
26
|
+
import { webProfileDir } from './env.js';
|
|
27
|
+
export class ToggleError extends Error {
|
|
28
|
+
code;
|
|
29
|
+
constructor(code, message) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.code = code;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** 官方 change() 富结果的鸭子读(不建类型依赖)。 */
|
|
35
|
+
function applicationOf(result) {
|
|
36
|
+
if (result && typeof result === 'object') {
|
|
37
|
+
const record = result;
|
|
38
|
+
return {
|
|
39
|
+
application: typeof record.application === 'string' ? record.application : undefined,
|
|
40
|
+
warnings: Array.isArray(record.warnings) ? record.warnings.map((w) => String(w)) : [],
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
return { warnings: [] };
|
|
44
|
+
}
|
|
45
|
+
async function readPatchText(io, path) {
|
|
46
|
+
try {
|
|
47
|
+
return await io.readFile(path, 'utf8');
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
if (error.code === 'ENOENT')
|
|
51
|
+
return '[]\n';
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export async function togglePlugin(pkg, enabled, deps = {}) {
|
|
56
|
+
const key = String(pkg || '').trim();
|
|
57
|
+
// ① 保护门最先:纯名单检查(空 profile 也先于 not-installed 命中)
|
|
58
|
+
if (key === 'dsh-m' || PROTECTED_MODULES.includes(key)) {
|
|
59
|
+
throw new ToggleError('protected', `受保护插件不可开关: ${key}(dsh-m 自身或官方宿主命脉)`);
|
|
60
|
+
}
|
|
61
|
+
const io = deps.io ?? { readFile: fsReadFile };
|
|
62
|
+
const profileDir = deps.profileDir ?? webProfileDir();
|
|
63
|
+
// ② 在装判定
|
|
64
|
+
const installed = await listInstalledPlugins(profileDir);
|
|
65
|
+
const item = installed.items.find((it) => it.pkg === key);
|
|
66
|
+
if (item === undefined)
|
|
67
|
+
throw new ToggleError('not-installed', `web profile 未安装该插件: ${key}`);
|
|
68
|
+
const granularity = item.patchRows && isSingleRowPlugin(item.patchRows) ? 'row' : 'bundle';
|
|
69
|
+
const service = deps.getService?.();
|
|
70
|
+
if (service !== undefined) {
|
|
71
|
+
// 委派一律走 Bundle 选择(官方插件页主开关同层,两层 UI 状态一致;
|
|
72
|
+
// 行覆盖残留由启用路径的清理扫描兜底)。行覆盖仅是降级路径的实现细节。
|
|
73
|
+
return toggleViaService(service, item, enabled);
|
|
74
|
+
}
|
|
75
|
+
// 降级:Bundle 退选态的「启用」必须走 bundles 编辑(行覆盖救不回未合成的树)
|
|
76
|
+
let inBundles = false;
|
|
77
|
+
try {
|
|
78
|
+
const manifest = JSON.parse(await io.readFile(join(profileDir, 'package.json'), 'utf8'));
|
|
79
|
+
const bundles = manifest?.dsh?.profile?.bundles;
|
|
80
|
+
inBundles = Array.isArray(bundles) && bundles.includes(item.pkg);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
/* 读不到按不在 bundles 处理 */
|
|
84
|
+
}
|
|
85
|
+
const route = enabled && !inBundles ? 'bundle' : granularity;
|
|
86
|
+
return toggleFallback(item, enabled, route, { profileDir, io, loaderHost: deps.loaderHost ?? loaderHost() });
|
|
87
|
+
}
|
|
88
|
+
// ---------- 委派路径 ----------
|
|
89
|
+
async function toggleViaService(service, item, enabled) {
|
|
90
|
+
const warnings = [];
|
|
91
|
+
// 启用前清理本插件的历史行覆盖(dsh-m 单开关模型:行覆盖是它自己写下的,
|
|
92
|
+
// 不清则 bundle 重选后旧行仍 disabled,官方页第二层会出现一开一关)
|
|
93
|
+
if (enabled) {
|
|
94
|
+
const cleared = await clearRowOverrides(service, item.pkg, warnings);
|
|
95
|
+
if (cleared > 0)
|
|
96
|
+
warnings.push(`已清理 ${cleared} 条历史行覆盖`);
|
|
97
|
+
}
|
|
98
|
+
const raw = await service.setBundleEnabled(item.pkg, enabled);
|
|
99
|
+
const rich = applicationOf(raw);
|
|
100
|
+
warnings.push(...rich.warnings);
|
|
101
|
+
if (enabled) {
|
|
102
|
+
// selectBundle 后才可见的行覆盖(bundle 退选期 loader 无条目)再扫一遍
|
|
103
|
+
await clearRowOverrides(service, item.pkg, warnings);
|
|
104
|
+
}
|
|
105
|
+
// applied 复读:bundle on → 该模块行应存在且启用;bundle off → 行消失或全禁用
|
|
106
|
+
const after = (await service.listPlugins()).filter((r) => r.moduleName === item.pkg);
|
|
107
|
+
const running = after.some((r) => r.enabled);
|
|
108
|
+
let applied;
|
|
109
|
+
if (rich.application === 'restart-required')
|
|
110
|
+
applied = 'restart-required';
|
|
111
|
+
else
|
|
112
|
+
applied = running === enabled ? 'live' : 'restart-required';
|
|
113
|
+
if (rich.application === 'overridden')
|
|
114
|
+
warnings.push('被更高补丁层覆盖,落盘状态与请求不符——重启后以下层为准');
|
|
115
|
+
return { pkg: item.pkg, enabled, applied, via: 'delegate', warnings };
|
|
116
|
+
}
|
|
117
|
+
/** 清理本插件可见的 disabled 行覆盖(跳过官方 readOnly 行);返回清理条数。 */
|
|
118
|
+
async function clearRowOverrides(service, pkg, warnings) {
|
|
119
|
+
const rows = (await service.listPlugins()).filter((r) => r.moduleName === pkg && !r.readOnlyReason && r.enabled === false);
|
|
120
|
+
let cleared = 0;
|
|
121
|
+
for (const row of rows) {
|
|
122
|
+
const rich = applicationOf(await service.setPluginEnabled(row.entryId, true));
|
|
123
|
+
warnings.push(...rich.warnings);
|
|
124
|
+
cleared += 1;
|
|
125
|
+
}
|
|
126
|
+
return cleared;
|
|
127
|
+
}
|
|
128
|
+
async function toggleFallback(item, enabled, granularity, ctx) {
|
|
129
|
+
if (granularity === 'row') {
|
|
130
|
+
return toggleRowFallback(item, enabled, ctx);
|
|
131
|
+
}
|
|
132
|
+
return toggleBundleFallback(item, enabled, ctx);
|
|
133
|
+
}
|
|
134
|
+
/** 行覆盖降级:锁内保注释 YAML 编辑 + loader 活体翻转。 */
|
|
135
|
+
async function toggleRowFallback(item, enabled, ctx) {
|
|
136
|
+
const loaderEntry = findLoaderEntryByName(ctx.loaderHost, item.pkg);
|
|
137
|
+
const insert = item.patchRows?.readable ? item.patchRows.inserts[0] : undefined;
|
|
138
|
+
const id = loaderEntry?.id ?? insert?.id;
|
|
139
|
+
const name = loaderEntry?.options?.name ?? insert?.name;
|
|
140
|
+
if (id === undefined || id === '') {
|
|
141
|
+
throw new ToggleError('unknown-plugin', `无法定位该插件的合成条目 id(loader 与补丁行均无): ${item.pkg}`);
|
|
142
|
+
}
|
|
143
|
+
const patchPath = join(ctx.profileDir, 'cordis.patch.yml');
|
|
144
|
+
let liveFound = false;
|
|
145
|
+
await withFileLock(join(ctx.profileDir, 'package.json'), async () => {
|
|
146
|
+
const text = await readPatchText(ctx.io, patchPath);
|
|
147
|
+
const plan = planRowOverride(text, id, name, enabled);
|
|
148
|
+
if (plan.changed) {
|
|
149
|
+
await writeFileAtomic(patchPath, plan.text, { mode: 0o600 });
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
liveFound = await setLivePluginDisabled(item.pkg, !enabled, ctx.loaderHost);
|
|
153
|
+
return {
|
|
154
|
+
pkg: item.pkg,
|
|
155
|
+
enabled,
|
|
156
|
+
applied: liveFound ? 'live' : 'restart-required',
|
|
157
|
+
via: 'fallback',
|
|
158
|
+
warnings: [],
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
function findLoaderEntryByName(host, pkg) {
|
|
162
|
+
const entries = host?.loader?.entries;
|
|
163
|
+
if (typeof entries !== 'function')
|
|
164
|
+
return null;
|
|
165
|
+
for (const entry of entries.call(host.loader)) {
|
|
166
|
+
if (entry && entry.options?.name === pkg)
|
|
167
|
+
return entry;
|
|
168
|
+
}
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
/** Bundle 选择降级:锁内 bundles 数组编辑(恒 restart-required)。 */
|
|
172
|
+
async function toggleBundleFallback(item, enabled, ctx) {
|
|
173
|
+
const manifestPath = join(ctx.profileDir, 'package.json');
|
|
174
|
+
await withFileLock(join(ctx.profileDir, 'package.json'), async () => {
|
|
175
|
+
if (enabled) {
|
|
176
|
+
// 启用即清本包历史行覆盖(与委派路径同语义)
|
|
177
|
+
const patchPath = join(ctx.profileDir, 'cordis.patch.yml');
|
|
178
|
+
let patchText = '[]\n';
|
|
179
|
+
try {
|
|
180
|
+
patchText = await ctx.io.readFile(patchPath, 'utf8');
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
if (error.code !== 'ENOENT')
|
|
184
|
+
throw error;
|
|
185
|
+
}
|
|
186
|
+
let patchMutated = false;
|
|
187
|
+
for (const insert of item.patchRows?.inserts ?? []) {
|
|
188
|
+
const plan = planRowOverride(patchText, insert.id, insert.name, true);
|
|
189
|
+
if (plan.changed) {
|
|
190
|
+
patchText = plan.text;
|
|
191
|
+
patchMutated = true;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (patchMutated)
|
|
195
|
+
await writeFileAtomic(patchPath, patchText, { mode: 0o600 });
|
|
196
|
+
}
|
|
197
|
+
let raw;
|
|
198
|
+
try {
|
|
199
|
+
raw = await ctx.io.readFile(manifestPath, 'utf8');
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
if (error.code === 'ENOENT')
|
|
203
|
+
raw = '{}';
|
|
204
|
+
else
|
|
205
|
+
throw error;
|
|
206
|
+
}
|
|
207
|
+
const doc = JSON.parse(raw);
|
|
208
|
+
if (typeof doc.dsh !== 'object' || doc.dsh === null)
|
|
209
|
+
doc.dsh = {};
|
|
210
|
+
if (typeof doc.dsh.profile !== 'object' || doc.dsh.profile === null)
|
|
211
|
+
doc.dsh.profile = {};
|
|
212
|
+
const previous = Array.isArray(doc.dsh.profile.bundles) ? doc.dsh.profile.bundles : [];
|
|
213
|
+
const bundles = enabled
|
|
214
|
+
? previous.includes(item.pkg) ? previous : [...previous, item.pkg]
|
|
215
|
+
: previous.filter((name) => name !== item.pkg);
|
|
216
|
+
doc.dsh.profile.bundles = bundles;
|
|
217
|
+
await writeFileAtomic(manifestPath, `${JSON.stringify(doc, null, 2)}\n`, { mode: 0o600 });
|
|
218
|
+
});
|
|
219
|
+
return { pkg: item.pkg, enabled, applied: 'restart-required', via: 'fallback', warnings: [] };
|
|
220
|
+
}
|
package/lib/core/versions.js
CHANGED
|
@@ -18,43 +18,56 @@ function githubRateLimitMessage(err) {
|
|
|
18
18
|
}
|
|
19
19
|
return null;
|
|
20
20
|
}
|
|
21
|
+
/** registry base 归一(去尾斜杠;空/缺省 → 官方 npmjs)。 */
|
|
22
|
+
function registryBase(registry) {
|
|
23
|
+
const base = typeof registry === 'string' && registry.trim() !== '' ? registry.trim() : 'https://registry.npmjs.org';
|
|
24
|
+
return base.replace(/\/+$/, '');
|
|
25
|
+
}
|
|
21
26
|
/** 精确 semver:接受 prerelease/build metadata,拒绝 v 前缀、range、tag 与脏尾缀。 */
|
|
22
27
|
const EXACT_VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
|
|
23
28
|
export function isExactVersion(version) {
|
|
24
29
|
return EXACT_VERSION_RE.test(version);
|
|
25
30
|
}
|
|
26
31
|
/** 读取该精确版本的 dist metadata(不使用 /latest endpoint);integrity 缺失由调用方拒绝安装。 */
|
|
27
|
-
export async function npmVersion(pkg, version, timeoutMs = 20_000, signal) {
|
|
32
|
+
export async function npmVersion(pkg, version, timeoutMs = 20_000, signal, registry) {
|
|
28
33
|
if (!/^@?[A-Za-z0-9-._~]+(\/[A-Za-z0-9-._~]+)?$/.test(pkg))
|
|
29
34
|
throw new Error(`无效 npm 包名: ${pkg}`);
|
|
30
35
|
if (!isExactVersion(version))
|
|
31
36
|
throw new Error(`不是精确版本(拒绝 range/tag/前缀): ${version}`);
|
|
32
|
-
const data = await fetchJsonLimited(
|
|
37
|
+
const data = await fetchJsonLimited(`${registryBase(registry)}/${encodeURIComponent(pkg)}/${version}`, { timeoutMs, signal });
|
|
33
38
|
const resolved = typeof data.version === 'string' ? data.version : '';
|
|
34
39
|
if (!resolved)
|
|
35
40
|
throw new Error(`npm 未返回版本: ${pkg}@${version}`);
|
|
41
|
+
const peers = {};
|
|
42
|
+
if (data.peerDependencies !== null && typeof data.peerDependencies === 'object') {
|
|
43
|
+
for (const [name, range] of Object.entries(data.peerDependencies)) {
|
|
44
|
+
if (typeof range === 'string')
|
|
45
|
+
peers[name] = range;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
36
48
|
return {
|
|
37
49
|
version: resolved,
|
|
38
50
|
integrity: typeof data.dist?.integrity === 'string' ? data.dist.integrity : undefined,
|
|
39
51
|
tarball: typeof data.dist?.tarball === 'string' ? data.dist.tarball : undefined,
|
|
52
|
+
peers,
|
|
40
53
|
};
|
|
41
54
|
}
|
|
42
55
|
/**
|
|
43
56
|
* 拉取完整 packument(NO_MATCHING_VERSION 退避重试前的预热/校验原语)。
|
|
44
57
|
* 返回该包已知的全部版本号;解析不出 versions 时返回空列表(不抛)。
|
|
45
58
|
*/
|
|
46
|
-
export async function npmPackument(pkg, timeoutMs = 20_000, signal) {
|
|
59
|
+
export async function npmPackument(pkg, timeoutMs = 20_000, signal, registry) {
|
|
47
60
|
if (!/^@?[A-Za-z0-9-._~]+(\/[A-Za-z0-9-._~]+)?$/.test(pkg))
|
|
48
61
|
throw new Error(`无效 npm 包名: ${pkg}`);
|
|
49
62
|
const data = await fetchJsonLimited(`https://registry.npmjs.org/${encodeURIComponent(pkg)}`, { timeoutMs, signal, maxBytes: 8 * 1024 * 1024 });
|
|
50
63
|
const versions = data?.versions !== null && typeof data?.versions === 'object' ? Object.keys(data.versions) : [];
|
|
51
64
|
return { versions };
|
|
52
65
|
}
|
|
53
|
-
export async function npmLatest(pkg, timeoutMs = 20_000, signal) {
|
|
66
|
+
export async function npmLatest(pkg, timeoutMs = 20_000, signal, registry) {
|
|
54
67
|
// 允许 scoped 包名:@scope/name(isSafePkgName 同款字符集)
|
|
55
68
|
if (!/^@?[A-Za-z0-9-._~]+(\/[A-Za-z0-9-._~]+)?$/.test(pkg))
|
|
56
69
|
throw new Error(`无效 npm 包名: ${pkg}`);
|
|
57
|
-
const data = await fetchJsonLimited(
|
|
70
|
+
const data = await fetchJsonLimited(`${registryBase(registry)}/${encodeURIComponent(pkg)}/latest`, { timeoutMs, signal });
|
|
58
71
|
const version = typeof data.version === 'string' ? data.version : '';
|
|
59
72
|
if (!version)
|
|
60
73
|
throw new Error(`npm 未返回版本: ${pkg}`);
|
package/lib/host.js
CHANGED
|
@@ -3,44 +3,121 @@ import Schema from '@deepseek-ai/schemastery';
|
|
|
3
3
|
import { createApiDispatcher } from './core/host-api.js';
|
|
4
4
|
import { bindLoaderHost } from './core/live-plugin.js';
|
|
5
5
|
import { createRegistryController } from './core/registry-controller.js';
|
|
6
|
+
import { wireRegistrySettings, unwrapConfig, unwrapProbeConfig, detectSettingsKind, ownEntryId } from './core/settings-compat.js';
|
|
6
7
|
import { registerTools } from './tools.js';
|
|
7
8
|
import { appExitFromContext, scheduleRestart } from './core/restart.js';
|
|
9
|
+
import { RegistryProbe } from './core/registry-probe.js';
|
|
8
10
|
const require = createRequire(import.meta.url);
|
|
9
11
|
const pkg = require('../package.json');
|
|
10
12
|
export const name = 'dshm';
|
|
11
13
|
// dshm_* 七个工具(src/tools.ts)
|
|
12
14
|
export const inject = ['tools'];
|
|
15
|
+
/**
|
|
16
|
+
* 0.1.7+ 的 DSH schemastery 才有 `.volatile()`(标记字段进自动生成设置页 + loader 原地提交);
|
|
17
|
+
* 旧版(≤0.1.5,schemastery 3.18.2)缺方法时保持普通字段——旧 settings API 的 scope 自带 live 语义。
|
|
18
|
+
* `volatile()` 幂等性:重复包裹会 throw,这里每字段只调一次。
|
|
19
|
+
*/
|
|
20
|
+
function live(field) {
|
|
21
|
+
const volatile = field.volatile;
|
|
22
|
+
return typeof volatile === 'function' ? volatile.call(field) : field;
|
|
23
|
+
}
|
|
13
24
|
export const Config = Schema.object({
|
|
14
|
-
registryUrl: Schema.string().description('registry 地址:空值使用默认官方清单;支持 HTTPS URL、loopback HTTP URL 或本机绝对路径/file://(整体覆盖默认清单,live 生效)'),
|
|
15
|
-
timeoutMs: Schema.number().default(20000).description('上游请求超时(毫秒)'),
|
|
16
|
-
cacheTtlMin: Schema.number().default(60).description('registry 缓存时长(分钟)'),
|
|
25
|
+
registryUrl: live(Schema.string().description('registry 地址:空值使用默认官方清单;支持 HTTPS URL、loopback HTTP URL 或本机绝对路径/file://(整体覆盖默认清单,live 生效)')),
|
|
26
|
+
timeoutMs: live(Schema.number().default(20000).description('上游请求超时(毫秒)')),
|
|
27
|
+
cacheTtlMin: live(Schema.number().default(60).description('registry 缓存时长(分钟)')),
|
|
28
|
+
probeEnabled: live(Schema.boolean().default(true).description('元数据源竞速探测:npmjs 与 npmmirror ping 并发,首个 2xx 胜出;只影响 dsh-m 元数据读取源与市场页展示,不影响安装链路')),
|
|
29
|
+
probeTimeoutMs: live(Schema.number().default(1500).description('竞速探测单侧超时(毫秒)')),
|
|
30
|
+
probeCacheTtlMin: live(Schema.number().default(5).description('竞速探测结果缓存时长(分钟)')),
|
|
17
31
|
});
|
|
18
32
|
export function apply(ctx, config) {
|
|
19
33
|
// 卸载前的 live-disable 依赖 loader(skillhub 同款)
|
|
20
34
|
bindLoaderHost(ctx);
|
|
21
35
|
// registry controller:active config / configured / pending / rejected 分离 + generation fence;
|
|
22
|
-
// tools 与 Host API 共用同一 active config object(apply 原地更新字段,live
|
|
23
|
-
|
|
24
|
-
|
|
36
|
+
// tools 与 Host API 共用同一 active config object(apply 原地更新字段,live 生效)。
|
|
37
|
+
// unwrapConfig:0.1.7 上 volatile 字段是 cosmokit Volatile 引用,先解包成 plain 值再进 controller。
|
|
38
|
+
const controller = createRegistryController(unwrapConfig(config));
|
|
39
|
+
// DSH 0.1.2-rc.1 / 0.1.5-rc.1 / 0.1.7-rc.1 / 0.1.7-rc.2 都经 dsh-cmdline 暴露 appExit;
|
|
25
40
|
// using it avoids guessing the service unit from release-specific cgroups.
|
|
26
41
|
const appExit = appExitFromContext(ctx);
|
|
27
42
|
const restart = (port = null) => scheduleRestart(port, { appExit });
|
|
43
|
+
// 元数据源竞速探测(Task 7/14):timeoutMs 取启动时配置(实例持有缓存,改参数重启生效);
|
|
44
|
+
// probeEnabled 每次调用实时读取(设置页关掉立即生效)。
|
|
45
|
+
const probe = new RegistryProbe({
|
|
46
|
+
timeoutMs: unwrapProbeConfig(config).probeTimeoutMs ?? 1500,
|
|
47
|
+
cacheTtlMs: (unwrapProbeConfig(config).probeCacheTtlMin ?? 5) * 60_000,
|
|
48
|
+
});
|
|
49
|
+
const probeLike = {
|
|
50
|
+
fastest: () => {
|
|
51
|
+
if (unwrapProbeConfig(config).probeEnabled === false)
|
|
52
|
+
return Promise.resolve(null);
|
|
53
|
+
return probe.fastest();
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
// 开关委派的官方服务探测(ADR-0001):运行时按服务存在性探测,不判 DSH 版本号
|
|
57
|
+
const getService = () => ctx.get?.('pluginManager');
|
|
28
58
|
registerTools(ctx, controller.config, { restart });
|
|
29
|
-
//
|
|
59
|
+
// 设置页接线(双代兼容,详见 core/settings-compat.ts):
|
|
60
|
+
// - ≤0.1.5:settings.register('dshm', …) scope(get/update/watch);
|
|
61
|
+
// - 0.1.7-rc.1 / rc.2:Config `.volatile()` 字段自动生成设置页(autoGenerate 默认开,无需 configure),
|
|
62
|
+
// 读 = loader 解析的 Config 引用,写 = settings.update(ns, patch),通知 = loader/volatile-update;
|
|
63
|
+
// - 其他形态:降级 cordis 配置文件通路。任何失败只 warn,不拖垮 webServer / tools。
|
|
64
|
+
// probe 配置写入口(Task 18 Rev):forms 形态走 settings.update(落盘 + 活体);
|
|
65
|
+
// legacy/其余形态就地改 config(probeLike 每次调用现读,活体生效但不保证持久化)。
|
|
66
|
+
let probeWriter = null;
|
|
30
67
|
ctx.inject(['settings'], (c) => {
|
|
31
68
|
const settings = c.settings;
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
69
|
+
if (detectSettingsKind(settings) === 'forms') {
|
|
70
|
+
const ns = ownEntryId(ctx, pkg.name);
|
|
71
|
+
if (ns !== undefined) {
|
|
72
|
+
const forms = settings;
|
|
73
|
+
probeWriter = async (patch) => { await forms.update(ns, patch); };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (probeWriter === null) {
|
|
77
|
+
probeWriter = async (patch) => {
|
|
78
|
+
const target = config;
|
|
79
|
+
for (const [k, v] of Object.entries(patch))
|
|
80
|
+
target[k] = v;
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
const outcome = wireRegistrySettings({
|
|
85
|
+
ctx,
|
|
86
|
+
settings,
|
|
87
|
+
schema: Config,
|
|
88
|
+
parsedConfig: config,
|
|
89
|
+
packageName: pkg.name,
|
|
90
|
+
logger: ctx.logger,
|
|
91
|
+
attachStore: (store) => controller.attachStore(store),
|
|
92
|
+
});
|
|
93
|
+
if (outcome === 'legacy' || outcome === 'forms') {
|
|
94
|
+
ctx.logger?.debug?.('dsh-m: settings store attached (%s)', outcome);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
ctx.logger?.warn?.('dsh-m: settings 集成失败,registry 地址走 cordis 配置文件');
|
|
99
|
+
ctx.logger?.warn?.(err);
|
|
100
|
+
}
|
|
39
101
|
});
|
|
40
102
|
// 本地 API:单路由 + method 分发(防护与状态映射在 core/host-api.ts)
|
|
41
103
|
ctx.inject(['webServer'], (c) => {
|
|
42
104
|
const server = c.webServer;
|
|
43
|
-
const handleApi = createApiDispatcher({
|
|
105
|
+
const handleApi = createApiDispatcher({
|
|
106
|
+
controller,
|
|
107
|
+
pkg,
|
|
108
|
+
deps: {
|
|
109
|
+
scheduleRestart: restart,
|
|
110
|
+
probe: probeLike,
|
|
111
|
+
probeSnapshot: () => probe.cachedSnapshot(),
|
|
112
|
+
getService,
|
|
113
|
+
probeConfigGet: () => unwrapProbeConfig(config),
|
|
114
|
+
probeConfigSet: async (patch) => {
|
|
115
|
+
if (!probeWriter)
|
|
116
|
+
throw new Error('设置服务缺席,probe 配置仅可经 cordis 配置文件修改');
|
|
117
|
+
await probeWriter(patch);
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
});
|
|
44
121
|
server.register({
|
|
45
122
|
kind: 'exact',
|
|
46
123
|
path: '/dshm',
|
package/lib/tools.js
CHANGED
|
@@ -2,6 +2,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
|
2
2
|
import { installTimeoutMs } from './core/env.js';
|
|
3
3
|
import { installFromRegistry, listInstalledWithMeta, listMarket, uninstallPlugin, upgradePlugin, } from './core/market.js';
|
|
4
4
|
import { appExitFromContext, scheduleRestart } from './core/restart.js';
|
|
5
|
+
import { togglePlugin as coreTogglePlugin } from './core/toggle.js';
|
|
5
6
|
export const CATEGORY_LABELS = {
|
|
6
7
|
market: '市场',
|
|
7
8
|
tools: '工具',
|
|
@@ -25,6 +26,9 @@ export function registerTools(ctx, cfg, deps = {}) {
|
|
|
25
26
|
upgradePlugin: deps.upgradePlugin ?? upgradePlugin,
|
|
26
27
|
};
|
|
27
28
|
const restart = deps.restart ?? ((port = null) => scheduleRestart(port, { appExit: appExitFromContext(ctx) }));
|
|
29
|
+
const toggle = deps.togglePlugin ?? coreTogglePlugin;
|
|
30
|
+
// 开关委派服务探测(ADR-0001):运行时按存在性,不判版本号
|
|
31
|
+
const getService = () => ctx.get?.('pluginManager');
|
|
28
32
|
ctx.tools.register(defineTool({
|
|
29
33
|
name: 'dshm_search',
|
|
30
34
|
description: 'Search your personal DSH plugin marketplace (dsh-m) and show clickable plugin cards. ALWAYS call this instead of web_search or bash when the user wants to find/recommend/browse their curated DSH plugins (插件). Call EXACTLY ONCE per user message; extract a real keyword (主题, 搜索) rather than pasting the whole sentence. Omit query to browse all listings. After cards appear, reply with AT MOST one short sentence. Do not print install commands.',
|
|
@@ -130,10 +134,11 @@ export function registerTools(ctx, cfg, deps = {}) {
|
|
|
130
134
|
}));
|
|
131
135
|
ctx.tools.register(defineTool({
|
|
132
136
|
name: 'dshm_install',
|
|
133
|
-
description: 'Install a plugin from the dsh-m registry into the current web profile after the user names one (装 dsh-skins / 安装 web-search). Pass the id from dshm_search results. npm 源锁定最新精确版本,github 源锁定 commit SHA
|
|
137
|
+
description: 'Install a plugin from the dsh-m registry into the current web profile after the user names one (装 dsh-skins / 安装 web-search). Pass the id from dshm_search results. npm 源锁定最新精确版本,github 源锁定 commit SHA。安装前做 peer 兼容预检(只检 @deepseek-ai/dsh(-*);github 源不预检):不兼容时返回结构化结果,向用户说明风险,用户确认后带 force: true 重试(force 等价 forceIncompatible)。Do not print CLI commands. After success, tell the user it needs a restart of dsh web, and offer dshm_restart.',
|
|
134
138
|
parameters: {
|
|
135
139
|
id: { type: 'string', required: true, description: '收录 id from dshm_search, e.g. dsh-skins' },
|
|
136
140
|
version: { type: 'string', description: 'Optional exact semver (npm 源). Default latest.' },
|
|
141
|
+
force: { type: 'boolean', description: '用户已确认兼容风险后重试时置 true(跳过 peer 预检拦截)。' },
|
|
137
142
|
},
|
|
138
143
|
output: {
|
|
139
144
|
schema: { type: 'object', additionalProperties: true },
|
|
@@ -152,7 +157,40 @@ export function registerTools(ctx, cfg, deps = {}) {
|
|
|
152
157
|
if (!id)
|
|
153
158
|
throw new Error('缺少收录 id');
|
|
154
159
|
const version = typeof args.version === 'string' && args.version.trim() ? args.version.trim() : undefined;
|
|
155
|
-
|
|
160
|
+
const force = args.force === true;
|
|
161
|
+
return cloneJson(await m.installFromRegistry(id, cfg, { version, forceIncompatible: force, namespace: 'host' }));
|
|
162
|
+
},
|
|
163
|
+
}));
|
|
164
|
+
ctx.tools.register(defineTool({
|
|
165
|
+
name: 'dshm_toggle',
|
|
166
|
+
description: 'Toggle a DSH plugin on/off (切换运行状态). Pass pkg from dshm_list and enabled (true=启用 / false=停用). 可逆操作:默认先与用户确认;用户同一句消息已明确表达(如「把 skins 关掉」)可直接执行。结果 applied=live 时告知已即时生效;restart-required 时提示需重启并提供 dshm_restart。受保护插件(dsh-m 自身与官方宿主命脉)会被拒绝。',
|
|
167
|
+
parameters: {
|
|
168
|
+
pkg: { type: 'string', required: true, description: '包名 from dshm_list, e.g. dsh-better-sidebar' },
|
|
169
|
+
enabled: { type: 'boolean', required: true, description: 'true = 启用,false = 停用' },
|
|
170
|
+
},
|
|
171
|
+
output: {
|
|
172
|
+
schema: { type: 'object', additionalProperties: true },
|
|
173
|
+
render: (_args, value) => [{ type: 'text', text: renderToggle(value) }],
|
|
174
|
+
presentationMeta: (_args, value) => ({ kind: 'dshm-toggle', ...value }),
|
|
175
|
+
},
|
|
176
|
+
presentCall: (args) => ({
|
|
177
|
+
card: 'generic',
|
|
178
|
+
title: `${args.enabled === true ? '启用' : '停用'} · ${String(args.pkg || '')}`,
|
|
179
|
+
content: [],
|
|
180
|
+
}),
|
|
181
|
+
presentResult: (_args, { isError, meta }) => ({
|
|
182
|
+
card: 'generic',
|
|
183
|
+
title: isError ? '开关操作失败' : `已${meta?.enabled === true ? '启用' : '停用'} · ${meta?.pkg || ''}`,
|
|
184
|
+
content: [],
|
|
185
|
+
}),
|
|
186
|
+
timeoutMs: 30_000,
|
|
187
|
+
async execute(args) {
|
|
188
|
+
const target = String(args.pkg || '').trim();
|
|
189
|
+
if (!target)
|
|
190
|
+
throw new Error('缺少 pkg');
|
|
191
|
+
if (typeof args.enabled !== 'boolean')
|
|
192
|
+
throw new Error('缺少 enabled(boolean)');
|
|
193
|
+
return cloneJson(await toggle(target, args.enabled, { getService: getService }));
|
|
156
194
|
},
|
|
157
195
|
}));
|
|
158
196
|
ctx.tools.register(defineTool({
|
|
@@ -272,11 +310,29 @@ export function registerTools(ctx, cfg, deps = {}) {
|
|
|
272
310
|
`Plugin categories: ${Object.entries(CATEGORY_LABELS).map(([k, v]) => `${k}=${v}`).join(', ')}.`,
|
|
273
311
|
'Install only after the user names a card: dshm_install with its id. Then one short sentence mentioning the restart requirement; offer dshm_restart.',
|
|
274
312
|
'For installed plugins, call dshm_list / dshm_outdated. Upgrade only after the user confirms which one: dshm_upgrade. Uninstall only after confirmation: dshm_uninstall.',
|
|
313
|
+
'dshm_toggle switches a plugin on/off: confirm first unless the user already said it in the same message (把 skins 关掉 → execute directly); report live vs restart-required accordingly. ',
|
|
275
314
|
'dshm_restart only after the user agrees to restart; afterwards tell them to refresh once the page recovers.',
|
|
276
315
|
].join(' '),
|
|
277
316
|
});
|
|
278
317
|
});
|
|
279
318
|
}
|
|
319
|
+
/** 开关结果渲染(Task 15)。 */
|
|
320
|
+
function renderToggle(out) {
|
|
321
|
+
const word = out.enabled ? '已启用' : '已停用';
|
|
322
|
+
if (out.applied === 'live') {
|
|
323
|
+
return `✅ ${out.pkg} ${word}(即时生效)。${out.warnings.join(';')}`;
|
|
324
|
+
}
|
|
325
|
+
return `✅ ${out.pkg} ${word}(需重启生效——询问是否 dshm_restart)。${out.warnings.join(';')}`;
|
|
326
|
+
}
|
|
327
|
+
/** 构建放行文案(ADR-0002):列包名;兜底全量放行如实标注。 */
|
|
328
|
+
function buildsNote(out) {
|
|
329
|
+
if (out.fallbackAllBuilds)
|
|
330
|
+
return '注意:该插件执行了构建脚本(名单不可读,已全量兜底放行)。';
|
|
331
|
+
const names = out.buildApprovals ?? [];
|
|
332
|
+
if (names.length === 0)
|
|
333
|
+
return '';
|
|
334
|
+
return `注意:该插件执行了构建脚本,已精确放行:${names.join('、')}。`;
|
|
335
|
+
}
|
|
280
336
|
function renderSearch(out) {
|
|
281
337
|
if (!out.items?.length)
|
|
282
338
|
return '收录清单中没有匹配的插件。对用户只说一句:没找到,可以换个词再搜。不要写长文。';
|
|
@@ -307,11 +363,19 @@ function renderList(out) {
|
|
|
307
363
|
].join('\n');
|
|
308
364
|
}
|
|
309
365
|
function renderInstall(out) {
|
|
310
|
-
const extra = out
|
|
366
|
+
const extra = buildsNote(out);
|
|
367
|
+
const compatNote = out.compat
|
|
368
|
+
? '注意:该版本与当前 DSH 运行时 peer 不兼容,已按用户确认强制安装。'
|
|
369
|
+
: out.compatSkipped === 'github-source'
|
|
370
|
+
? '注意:github 源未做兼容预检。'
|
|
371
|
+
: '';
|
|
372
|
+
const bundleNote = out.bundleWarning === 'no-patch-layer'
|
|
373
|
+
? '注意:该包无补丁层(cordis.patch.yml 缺失且未进 bundles),已装入为纯依赖不会生效;如非预期可卸载(dshm_uninstall)或到收录仓库反馈。'
|
|
374
|
+
: '';
|
|
311
375
|
const heals = out.healActions?.length
|
|
312
376
|
? `安装过程含 ${out.healActions.length} 步自愈(${out.healActions.map((h) => h.code).join('、')})。`
|
|
313
377
|
: '';
|
|
314
|
-
return `✅ ${out.pkg} 已安装(${out.spec})。${extra}${heals}需要重启 DSH Web 生效——告知用户并询问是否 dshm_restart。不要打印安装命令。`;
|
|
378
|
+
return `✅ ${out.pkg} 已安装(${out.spec})。${extra}${compatNote}${bundleNote}${heals}需要重启 DSH Web 生效——告知用户并询问是否 dshm_restart。不要打印安装命令。`;
|
|
315
379
|
}
|
|
316
380
|
function renderUninstall(out) {
|
|
317
381
|
const parts = [`✅ ${out.pkg} 已卸载。`];
|
|
@@ -338,7 +402,7 @@ function renderOutdated(out) {
|
|
|
338
402
|
function renderUpgrade(out) {
|
|
339
403
|
const from = out.fromVersion ? `v${out.fromVersion} → ` : '';
|
|
340
404
|
const to = out.version ? `v${out.version}` : out.sha ? out.sha.slice(0, 7) : '最新';
|
|
341
|
-
const extra = out
|
|
405
|
+
const extra = buildsNote(out);
|
|
342
406
|
return `✅ ${out.pkg} 已升级(${from}${to})。${extra}需要重启生效——询问是否 dshm_restart。`;
|
|
343
407
|
}
|
|
344
408
|
function clamp(n, min, max) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-m",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "可自定义收录清单的 DSH 插件市场:收录、安装、卸载、升级一站完成,附 agent 工具与 dshm CLI。",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -74,9 +74,15 @@
|
|
|
74
74
|
"@types/node": "^24.0.0",
|
|
75
75
|
"@types/react": "^18.3.0",
|
|
76
76
|
"@types/react-dom": "^18.3.0",
|
|
77
|
+
"@types/semver": "7.8.0",
|
|
77
78
|
"esbuild": "^0.25.0",
|
|
78
79
|
"react": "^18.3.1",
|
|
79
80
|
"react-dom": "^18.3.1",
|
|
80
81
|
"typescript": "^5.6.0"
|
|
82
|
+
},
|
|
83
|
+
"dependencies": {
|
|
84
|
+
"@deepseek-ai/dsh-atomic-write": "0.1.7-rc.2",
|
|
85
|
+
"semver": "7.8.5",
|
|
86
|
+
"yaml": "2.9.1"
|
|
81
87
|
}
|
|
82
88
|
}
|