dsh-plugin-manager-plus 1.3.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/LICENSE +21 -0
- package/README.md +149 -0
- package/cordis.patch.yml +17 -0
- package/lib/client.js +1240 -0
- package/lib/index.mjs +1227 -0
- package/package.json +55 -0
package/lib/index.mjs
ADDED
|
@@ -0,0 +1,1227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file lib/index.mjs
|
|
3
|
+
* @description DeepSeek Harness (DSH) 插件管理器 (dsh-plugin-manager) 宿主半边。
|
|
4
|
+
* 提供插件市场搜索代理、一键安装/卸载 npm 社区插件、启用/停用已安装插件(热重载)、
|
|
5
|
+
* 以及安全防护(Loopback 校验、Host 端口校验、CSRF 防御)。
|
|
6
|
+
* 所有变更持久化至 cordis.patch.yml 与 plugins-store/plugin-manager.json。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { existsSync, readFileSync, writeFileSync, copyFileSync, unlinkSync, rmSync, mkdirSync, lstatSync, renameSync, statSync } from 'node:fs';
|
|
10
|
+
import { resolve, dirname, join } from 'node:path';
|
|
11
|
+
import { spawn } from 'node:child_process';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
import { homedir } from 'node:os';
|
|
14
|
+
|
|
15
|
+
export const name = 'dsh-plugin-manager-plus';
|
|
16
|
+
export const inject = ['loader', 'webServer'];
|
|
17
|
+
|
|
18
|
+
const PLUGIN_VERSION = '1.3.0';
|
|
19
|
+
|
|
20
|
+
/** 读取宿主 dsh 包版本(后端进程 argv[1] 即 dsh 的 bin.js,其上级即包根) */
|
|
21
|
+
function readHostDshVersion() {
|
|
22
|
+
try {
|
|
23
|
+
const argv1 = process.argv[1] || '';
|
|
24
|
+
if (argv1 && argv1.endsWith('bin.js')) {
|
|
25
|
+
const pkgPath = join(dirname(dirname(argv1)), 'package.json');
|
|
26
|
+
if (existsSync(pkgPath)) {
|
|
27
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
28
|
+
if (typeof pkg.version === 'string') return pkg.version;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
} catch {}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
const HOST_DSH_VERSION = readHostDshVersion();
|
|
35
|
+
|
|
36
|
+
/** Fiber 状态常量映射 */
|
|
37
|
+
const FIBER_PHASE = {
|
|
38
|
+
0: 'pending',
|
|
39
|
+
1: 'loading',
|
|
40
|
+
2: 'active',
|
|
41
|
+
3: 'failed',
|
|
42
|
+
4: null,
|
|
43
|
+
5: 'unloading'
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** 官方包(@deepseek-ai/ 前缀)用途分类规则表 */
|
|
47
|
+
const FUNC_RULES = [
|
|
48
|
+
{ id: 'net', patterns: [/^@deepseek-ai\/dsh-web($|-)/, /^@deepseek-ai\/dsh-mcp/, /^@deepseek-ai\/dsh-api-/, /^@deepseek-ai\/dsh-host-webserver/, /^@deepseek-ai\/dsh-host-apiproxy/, /^@deepseek-ai\/dsh-client-connection/, /^@deepseek-ai\/dsh-cordis-(host|client)-runner/] },
|
|
49
|
+
{ id: 'dev', patterns: [/^@deepseek-ai\/dsh-tool-/, /^@deepseek-ai\/dsh-fs($|-)/, /^@deepseek-ai\/dsh-(bash|pwsh|terminal|tmux)/, /^@deepseek-ai\/dsh-subagent/, /^@deepseek-ai\/dsh-workflow/, /^@deepseek-ai\/dsh-(goal|plan-mode|ralph)/, /^@deepseek-ai\/dsh-code-runtime/, /^@deepseek-ai\/dsh-skill($|-)/, /^@deepseek-ai\/dsh-jobs($|-)/, /^@deepseek-ai\/dsh-(cmdline|native-command|headless|spill)/] },
|
|
50
|
+
{ id: 'llm', patterns: [/^@deepseek-ai\/dsh-llm/, /^@deepseek-ai\/dsh-agent($|-)/, /^@deepseek-ai\/dsh-model/, /^@deepseek-ai\/dsh-persona/, /^@deepseek-ai\/dsh-system-prompt/, /^@deepseek-ai\/dsh-agent-instructions/, /^@deepseek-ai\/dsh-token-meter/] },
|
|
51
|
+
{ id: 'chat', patterns: [/^@deepseek-ai\/dsh-session/, /^@deepseek-ai\/dsh-compaction/, /^@deepseek-ai\/dsh-message/, /^@deepseek-ai\/dsh-attachment/, /^@deepseek-ai\/dsh-command/, /^@deepseek-ai\/dsh-repeat/, /^@deepseek-ai\/dsh-(session|file)-reference/, /^@deepseek-ai\/dsh-conversation/] },
|
|
52
|
+
{ id: 'ui', patterns: [/^@deepseek-ai\/dsh-client-ui/, /^@deepseek-ai\/dsh-client-/, /^@deepseek-ai\/dsh-web-app/, /^@deepseek-ai\/dsh-brand/] },
|
|
53
|
+
{ id: 'infra', patterns: [/^@deepseek-ai\/dsh-(storage|sandbox|credentials|settings|subprocess|telemetry|scope|typert|invariants|shell|home-paths|launch|anonymous|time-context|host-frontend|workspace|output|timeout|user-questions|user-approval|permission|client-modules|repeat-tool)/, /^@deepseek-ai\/cordis/, /^cordis:/] },
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
/** 非 @deepseek-ai 包名 / file:// 路径(社区与自研)按关键词启发归类 */
|
|
57
|
+
const COMMUNITY_FUNC_RULES = [
|
|
58
|
+
{ id: 'dev', patterns: [/code|codex|claude|copilot|edit|test|git|terminal|shell|debug|build|review|lint|format|commit/i] },
|
|
59
|
+
{ id: 'chat', patterns: [/chat|im\b|bot|telegram|feishu|lark|dingtalk|qq|wechat|napcat|message|notify|gateway/i] },
|
|
60
|
+
{ id: 'ui', patterns: [/theme|skin|wallpaper|widget|panel|dashboard|status|indicator|icon|sound|sfx|market|manager/i] },
|
|
61
|
+
{ id: 'llm', patterns: [/model|llm|router|provider|memory|ocr|agent/i] },
|
|
62
|
+
{ id: 'net', patterns: [/proxy|network|remote|mobile|sync|import|export|web|server/i] },
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 判定插件用途分类 (funcCategory)
|
|
67
|
+
* @param {string} moduleName
|
|
68
|
+
* @returns {'net' | 'dev' | 'llm' | 'chat' | 'ui' | 'infra' | 'other'}
|
|
69
|
+
*/
|
|
70
|
+
function determineFuncCategory(moduleName) {
|
|
71
|
+
if (!moduleName) return 'other';
|
|
72
|
+
if (moduleName.startsWith('@deepseek-ai/') || moduleName.startsWith('cordis:') || moduleName.startsWith('@deepseek-ai/cordis')) {
|
|
73
|
+
for (const rule of FUNC_RULES) {
|
|
74
|
+
for (const pat of rule.patterns) {
|
|
75
|
+
if (pat.test(moduleName)) return rule.id;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// 社区或自研包 / file://
|
|
80
|
+
for (const rule of COMMUNITY_FUNC_RULES) {
|
|
81
|
+
for (const pat of rule.patterns) {
|
|
82
|
+
if (pat.test(moduleName)) return rule.id;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// 官方包未匹配上专有规则时 fallback
|
|
86
|
+
for (const rule of FUNC_RULES) {
|
|
87
|
+
for (const pat of rule.patterns) {
|
|
88
|
+
if (pat.test(moduleName)) return rule.id;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return 'other';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 判定插件来源分类 (category)
|
|
96
|
+
* @param {'builtin' | 'home'} source
|
|
97
|
+
* @param {string} entryId
|
|
98
|
+
* @param {string} moduleName
|
|
99
|
+
* @param {Set<string>} manifestEntryIds
|
|
100
|
+
* @returns {'core' | 'community' | 'user'}
|
|
101
|
+
*/
|
|
102
|
+
function determineCategory(source, entryId, moduleName, manifestEntryIds) {
|
|
103
|
+
if (source === 'builtin') {
|
|
104
|
+
return 'core';
|
|
105
|
+
}
|
|
106
|
+
// source === 'home'
|
|
107
|
+
if (manifestEntryIds.has(entryId)) {
|
|
108
|
+
return 'community';
|
|
109
|
+
}
|
|
110
|
+
if (moduleName && moduleName.startsWith('file://')) {
|
|
111
|
+
return 'user';
|
|
112
|
+
}
|
|
113
|
+
if (moduleName && !moduleName.startsWith('file://') && !moduleName.startsWith('http://') && !moduleName.startsWith('https://')) {
|
|
114
|
+
const webJunctionPath = join(DSH_HOME, 'profiles', 'web', 'node_modules', ...moduleName.split('/'));
|
|
115
|
+
try {
|
|
116
|
+
if (existsSync(webJunctionPath)) {
|
|
117
|
+
const stat = lstatSync(webJunctionPath);
|
|
118
|
+
if (stat.isSymbolicLink() || (stat.isDirectory() && process.platform === 'win32')) {
|
|
119
|
+
return 'user';
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
} catch {}
|
|
123
|
+
return 'community';
|
|
124
|
+
}
|
|
125
|
+
return 'community';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** 路径配置(可移植:环境变量优先,其余按 DSH Desktop 标准布局推导) */
|
|
129
|
+
function detectDshHome() {
|
|
130
|
+
if (process.env.DSH_HOME) return process.env.DSH_HOME;
|
|
131
|
+
// DSH Desktop 桌面壳标准布局:%APPDATA%\DSH Desktop\dsh-home(Windows);
|
|
132
|
+
// 其他平台回退 ~/.dsh(dsh CLI 的默认 DSH_HOME)。
|
|
133
|
+
const appData = process.env.APPDATA || (homedir() && join(homedir(), 'AppData', 'Roaming'));
|
|
134
|
+
if (appData) {
|
|
135
|
+
const desktopHome = join(appData, 'DSH Desktop', 'dsh-home');
|
|
136
|
+
if (existsSync(desktopHome)) return desktopHome;
|
|
137
|
+
}
|
|
138
|
+
return join(homedir(), '.dsh');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** 从后端进程 argv(node .../@deepseek-ai/dsh/lib/bin.js)推导 backend 根目录 */
|
|
142
|
+
function detectBackendDir() {
|
|
143
|
+
const argv1 = process.argv[1] || '';
|
|
144
|
+
if (argv1) {
|
|
145
|
+
// bin.js → lib → dsh(pkg) → @deepseek-ai → node_modules → dsh(dir) → backend
|
|
146
|
+
let dir = dirname(argv1);
|
|
147
|
+
for (let i = 0; i < 5; i++) dir = dirname(dir);
|
|
148
|
+
if (existsSync(join(dir, 'node_modules', 'npm', 'bin', 'npm-cli.js'))) return dir;
|
|
149
|
+
}
|
|
150
|
+
const appData = process.env.APPDATA || (homedir() && join(homedir(), 'AppData', 'Roaming'));
|
|
151
|
+
if (appData) {
|
|
152
|
+
const backend = join(appData, 'DSH Desktop', 'backend');
|
|
153
|
+
if (existsSync(backend)) return backend;
|
|
154
|
+
}
|
|
155
|
+
return '';
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const DSH_HOME = detectDshHome();
|
|
159
|
+
const PATCH_PATH = join(DSH_HOME, 'cordis.patch.yml');
|
|
160
|
+
const BACKUP_PATCH_PATH = join(DSH_HOME, 'cordis.patch.yml.bak-pm');
|
|
161
|
+
const STORE_DIR = join(DSH_HOME, 'plugins-store');
|
|
162
|
+
const MANIFEST_PATH = join(STORE_DIR, 'plugin-manager.json');
|
|
163
|
+
const WEB_PROFILE_DIR = join(DSH_HOME, 'profiles', 'web');
|
|
164
|
+
const WEB_PKG_JSON_PATH = join(WEB_PROFILE_DIR, 'package.json');
|
|
165
|
+
const BACKEND_DIR = detectBackendDir();
|
|
166
|
+
const DEFAULT_NPM_CLI = BACKEND_DIR ? join(BACKEND_DIR, 'node_modules', 'npm', 'bin', 'npm-cli.js') : '';
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* 串行任务队列状态
|
|
170
|
+
*/
|
|
171
|
+
const queueStatus = {
|
|
172
|
+
busy: false,
|
|
173
|
+
current: null, // { action, target, startedAt }
|
|
174
|
+
last: null // { action, target, ok, error, finishedAt }
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
/** 串行锁 Promise 链 */
|
|
178
|
+
let queueLock = Promise.resolve();
|
|
179
|
+
|
|
180
|
+
function runInQueue(action, target, taskFn) {
|
|
181
|
+
if (queueStatus.busy) {
|
|
182
|
+
return Promise.reject(new Error(`当前有任务 [${queueStatus.current?.action}: ${queueStatus.current?.target}] 正在执行,请稍候...`));
|
|
183
|
+
}
|
|
184
|
+
queueStatus.busy = true;
|
|
185
|
+
queueStatus.current = { action, target, startedAt: Date.now() };
|
|
186
|
+
|
|
187
|
+
const taskPromise = queueLock.then(async () => {
|
|
188
|
+
try {
|
|
189
|
+
const res = await taskFn();
|
|
190
|
+
queueStatus.last = { action, target, ok: true, finishedAt: Date.now() };
|
|
191
|
+
return res;
|
|
192
|
+
} catch (err) {
|
|
193
|
+
queueStatus.last = { action, target, ok: false, error: err.message, finishedAt: Date.now() };
|
|
194
|
+
throw err;
|
|
195
|
+
} finally {
|
|
196
|
+
queueStatus.busy = false;
|
|
197
|
+
queueStatus.current = null;
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
queueLock = taskPromise.catch(() => {});
|
|
202
|
+
return taskPromise;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* 确保目录存在
|
|
207
|
+
*/
|
|
208
|
+
function ensureDir(dirPath) {
|
|
209
|
+
if (!existsSync(dirPath)) {
|
|
210
|
+
mkdirSync(dirPath, { recursive: true });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* 读取 manifest 数据
|
|
216
|
+
*/
|
|
217
|
+
function readManifest() {
|
|
218
|
+
try {
|
|
219
|
+
if (!existsSync(MANIFEST_PATH)) return [];
|
|
220
|
+
const content = readFileSync(MANIFEST_PATH, 'utf8');
|
|
221
|
+
const data = JSON.parse(content);
|
|
222
|
+
return Array.isArray(data) ? data : [];
|
|
223
|
+
} catch {
|
|
224
|
+
return [];
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* 原子保存 manifest 数据
|
|
230
|
+
*/
|
|
231
|
+
function saveManifest(manifest) {
|
|
232
|
+
ensureDir(STORE_DIR);
|
|
233
|
+
const tmpPath = `${MANIFEST_PATH}.tmp`;
|
|
234
|
+
writeFileSync(tmpPath, JSON.stringify(manifest, null, 2), 'utf8');
|
|
235
|
+
renameSync(tmpPath, MANIFEST_PATH);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* 将 npm 包名转为安全的 entryId
|
|
240
|
+
* 如 @1e0zj/dsh-plugin-mall -> 1e0zj-dsh-plugin-mall
|
|
241
|
+
* 如 dsh-plugin-demo -> dsh-plugin-demo
|
|
242
|
+
*/
|
|
243
|
+
function packageNameToEntryId(pkgName) {
|
|
244
|
+
return pkgName
|
|
245
|
+
.replace(/^@/, '')
|
|
246
|
+
.replace(/[\/@_]/g, '-')
|
|
247
|
+
.toLowerCase();
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* 提取模块简短名称
|
|
252
|
+
*/
|
|
253
|
+
function moduleShortName(moduleName) {
|
|
254
|
+
if (!moduleName) return '';
|
|
255
|
+
const s = (moduleName.startsWith('@') ? moduleName.slice(moduleName.indexOf('/') + 1) : moduleName);
|
|
256
|
+
return s.replace(/^cordis:/, '').replace(/^cordis-plugin-/, '').replace(/^dsh-(?:host-|client-)?/, '');
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* 安全校验:Loopback、Host 端口与 CSRF 防御
|
|
261
|
+
*/
|
|
262
|
+
function validateRequestSecurity(req) {
|
|
263
|
+
// 1. Loopback 地址校验
|
|
264
|
+
const remoteAddr = req.socket.remoteAddress || '';
|
|
265
|
+
const isLoopback = remoteAddr === '127.0.0.1' || remoteAddr === '::1' || remoteAddr === '::ffff:127.0.0.1';
|
|
266
|
+
if (!isLoopback) {
|
|
267
|
+
return { ok: false, status: 403, message: 'Forbidden: Loopback access only' };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// 2. Host 端口校验
|
|
271
|
+
const hostHeader = req.headers.host || '';
|
|
272
|
+
const localPort = req.socket.localPort;
|
|
273
|
+
if (localPort) {
|
|
274
|
+
const hostPortMatch = hostHeader.match(/:(\d+)$/);
|
|
275
|
+
const hostPort = hostPortMatch ? Number(hostPortMatch[1]) : (req.socket.encrypted ? 443 : 80);
|
|
276
|
+
if (hostPort !== localPort) {
|
|
277
|
+
return { ok: false, status: 403, message: `Forbidden: Host port mismatch (expected ${localPort})` };
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// 3. CSRF 防护 (仅 POST/PUT/DELETE 请求)
|
|
282
|
+
if (req.method === 'POST' || req.method === 'PUT' || req.method === 'DELETE') {
|
|
283
|
+
const secFetchSite = req.headers['sec-fetch-site'];
|
|
284
|
+
if (secFetchSite) {
|
|
285
|
+
if (secFetchSite !== 'same-origin' && secFetchSite !== 'same-site' && secFetchSite !== 'none') {
|
|
286
|
+
return { ok: false, status: 403, message: 'Forbidden: CSRF blocked (sec-fetch-site)' };
|
|
287
|
+
}
|
|
288
|
+
} else {
|
|
289
|
+
const origin = req.headers.origin;
|
|
290
|
+
if (origin) {
|
|
291
|
+
try {
|
|
292
|
+
const originUrl = new URL(origin);
|
|
293
|
+
if (originUrl.host !== hostHeader) {
|
|
294
|
+
return { ok: false, status: 403, message: 'Forbidden: CSRF origin mismatch' };
|
|
295
|
+
}
|
|
296
|
+
} catch {
|
|
297
|
+
return { ok: false, status: 403, message: 'Forbidden: Invalid Origin header' };
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return { ok: true };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* 读取 HTTP 请求 Body JSON
|
|
308
|
+
*/
|
|
309
|
+
function readJsonBody(req) {
|
|
310
|
+
return new Promise((resolve, reject) => {
|
|
311
|
+
let body = '';
|
|
312
|
+
req.on('data', (chunk) => { body += chunk; });
|
|
313
|
+
req.on('end', () => {
|
|
314
|
+
if (!body) return resolve({});
|
|
315
|
+
try {
|
|
316
|
+
resolve(JSON.parse(body));
|
|
317
|
+
} catch (e) {
|
|
318
|
+
reject(new Error(`JSON 格式非法: ${e.message}`));
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
req.on('error', reject);
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* =========================================================================
|
|
327
|
+
* 家级 Patch (cordis.patch.yml) 解析与精确行级编辑核心
|
|
328
|
+
* =========================================================================
|
|
329
|
+
*/
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* 解析 cordis.patch.yml 顶层块
|
|
333
|
+
*/
|
|
334
|
+
function parsePatchYaml(text) {
|
|
335
|
+
const lines = text.split(/\r?\n/);
|
|
336
|
+
const blocks = [];
|
|
337
|
+
let currentBlock = null;
|
|
338
|
+
|
|
339
|
+
for (let i = 0; i < lines.length; i++) {
|
|
340
|
+
const line = lines[i];
|
|
341
|
+
// 顶层块标记:行首是 "- " 开头
|
|
342
|
+
if (/^-\s+/.test(line)) {
|
|
343
|
+
if (currentBlock) {
|
|
344
|
+
blocks.push(currentBlock);
|
|
345
|
+
}
|
|
346
|
+
currentBlock = {
|
|
347
|
+
startLine: i,
|
|
348
|
+
endLine: i,
|
|
349
|
+
lines: [line],
|
|
350
|
+
type: line.trim().startsWith('- insert:') ? 'insert' : 'override',
|
|
351
|
+
entries: []
|
|
352
|
+
};
|
|
353
|
+
} else {
|
|
354
|
+
if (currentBlock) {
|
|
355
|
+
currentBlock.lines.push(line);
|
|
356
|
+
currentBlock.endLine = i;
|
|
357
|
+
} else {
|
|
358
|
+
// 文件头部注释/空行
|
|
359
|
+
blocks.push({
|
|
360
|
+
startLine: i,
|
|
361
|
+
endLine: i,
|
|
362
|
+
lines: [line],
|
|
363
|
+
type: 'header',
|
|
364
|
+
entries: []
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
if (currentBlock) {
|
|
370
|
+
blocks.push(currentBlock);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// 深入解析每个 insert / override 块中的 entries
|
|
374
|
+
for (const block of blocks) {
|
|
375
|
+
if (block.type === 'insert') {
|
|
376
|
+
let currentEntry = null;
|
|
377
|
+
for (let j = 0; j < block.lines.length; j++) {
|
|
378
|
+
const l = block.lines[j];
|
|
379
|
+
// 子项格式通常为 4 空格缩进:' - id: xxx' 或 ' - id: xxx'
|
|
380
|
+
const idMatch = l.match(/^(\s+)-\s+id:\s*(['"]?)(.+?)\2\s*$/);
|
|
381
|
+
if (idMatch) {
|
|
382
|
+
if (currentEntry) block.entries.push(currentEntry);
|
|
383
|
+
currentEntry = {
|
|
384
|
+
id: idMatch[3].trim(),
|
|
385
|
+
name: '',
|
|
386
|
+
disabled: false,
|
|
387
|
+
indent: idMatch[1],
|
|
388
|
+
startIdx: j,
|
|
389
|
+
endIdx: j,
|
|
390
|
+
lines: [l]
|
|
391
|
+
};
|
|
392
|
+
} else if (currentEntry) {
|
|
393
|
+
currentEntry.lines.push(l);
|
|
394
|
+
currentEntry.endIdx = j;
|
|
395
|
+
const nameMatch = l.match(/^\s+name:\s*(['"]?)(.+?)\1\s*$/);
|
|
396
|
+
if (nameMatch) currentEntry.name = nameMatch[2].trim();
|
|
397
|
+
const disabledMatch = l.match(/^\s+disabled:\s*(true|false)\s*$/);
|
|
398
|
+
if (disabledMatch) currentEntry.disabled = disabledMatch[1] === 'true';
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
if (currentEntry) block.entries.push(currentEntry);
|
|
402
|
+
} else if (block.type === 'override') {
|
|
403
|
+
// 覆盖块格式:'- id: xxx'
|
|
404
|
+
const firstLine = block.lines[0];
|
|
405
|
+
const idMatch = firstLine.match(/^-\s+id:\s*(['"]?)(.+?)\1\s*$/);
|
|
406
|
+
let id = idMatch ? idMatch[2].trim() : '';
|
|
407
|
+
let disabled = false;
|
|
408
|
+
for (let j = 1; j < block.lines.length; j++) {
|
|
409
|
+
const l = block.lines[j];
|
|
410
|
+
const idSubMatch = l.match(/^\s+id:\s*(['"]?)(.+?)\1\s*$/);
|
|
411
|
+
if (!id && idSubMatch) id = idSubMatch[2].trim();
|
|
412
|
+
const disabledMatch = l.match(/^\s+disabled:\s*(true|false)\s*$/);
|
|
413
|
+
if (disabledMatch) disabled = disabledMatch[1] === 'true';
|
|
414
|
+
}
|
|
415
|
+
if (id) {
|
|
416
|
+
block.entries.push({
|
|
417
|
+
id,
|
|
418
|
+
name: '',
|
|
419
|
+
disabled,
|
|
420
|
+
startIdx: 0,
|
|
421
|
+
endIdx: block.lines.length - 1,
|
|
422
|
+
lines: block.lines
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return { lines, blocks };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* 读出家级 patch 中登记的所有条目(用于 inventory 对账)
|
|
433
|
+
*/
|
|
434
|
+
function getHomePatchEntries() {
|
|
435
|
+
if (!existsSync(PATCH_PATH)) return { insertEntries: new Map(), overrideEntries: new Map() };
|
|
436
|
+
try {
|
|
437
|
+
const text = readFileSync(PATCH_PATH, 'utf8');
|
|
438
|
+
const { blocks } = parsePatchYaml(text);
|
|
439
|
+
const insertEntries = new Map();
|
|
440
|
+
const overrideEntries = new Map();
|
|
441
|
+
|
|
442
|
+
for (const block of blocks) {
|
|
443
|
+
if (block.type === 'insert') {
|
|
444
|
+
for (const entry of block.entries) {
|
|
445
|
+
insertEntries.set(entry.id, entry);
|
|
446
|
+
}
|
|
447
|
+
} else if (block.type === 'override') {
|
|
448
|
+
for (const entry of block.entries) {
|
|
449
|
+
overrideEntries.set(entry.id, entry);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
return { insertEntries, overrideEntries };
|
|
454
|
+
} catch {
|
|
455
|
+
return { insertEntries: new Map(), overrideEntries: new Map() };
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* 原子保存 Patch 文件并建立滚动备份。
|
|
461
|
+
* 带乐观并发保护:expectedMtimeMs 提供时,写前比对当前 mtime——期间有其他写入者
|
|
462
|
+
* (其他 agent 会话 / 手工编辑)动过文件则拒绝写入返回 false,由调用方重读重试,
|
|
463
|
+
* 避免双方基于各自内存副本互相覆盖对方的修改。
|
|
464
|
+
*/
|
|
465
|
+
function safeWritePatchFile(newContent, expectedMtimeMs) {
|
|
466
|
+
if (typeof expectedMtimeMs === 'number' && existsSync(PATCH_PATH)) {
|
|
467
|
+
const currentMtime = statSync(PATCH_PATH).mtimeMs;
|
|
468
|
+
if (currentMtime !== expectedMtimeMs) {
|
|
469
|
+
return false; // 并发修改,调用方应重读重试
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
if (!existsSync(PATCH_PATH)) {
|
|
473
|
+
writeFileSync(PATCH_PATH, newContent, 'utf8');
|
|
474
|
+
return true;
|
|
475
|
+
}
|
|
476
|
+
// 备份原文件
|
|
477
|
+
try {
|
|
478
|
+
copyFileSync(PATCH_PATH, BACKUP_PATCH_PATH);
|
|
479
|
+
} catch {}
|
|
480
|
+
|
|
481
|
+
const tmpPath = `${PATCH_PATH}.tmp`;
|
|
482
|
+
writeFileSync(tmpPath, newContent, 'utf8');
|
|
483
|
+
renameSync(tmpPath, PATCH_PATH);
|
|
484
|
+
return true;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** 读取 patch 文本并记录 mtime 戳(供 safeWritePatchFile 乐观锁比对) */
|
|
488
|
+
function readPatchStamped() {
|
|
489
|
+
const text = existsSync(PATCH_PATH) ? readFileSync(PATCH_PATH, 'utf8') : '';
|
|
490
|
+
const mtimeMs = existsSync(PATCH_PATH) ? statSync(PATCH_PATH).mtimeMs : 0;
|
|
491
|
+
return { text, mtimeMs };
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* 乐观锁重试执行一次 patch 读-改-写:并发冲突时自动重读最新文件重试(最多 attempts 次)。
|
|
496
|
+
* @param {string} label 日志标签
|
|
497
|
+
* @param {(text: string) => string|null} transform 纯文本变换,返回 null 表示无需写入
|
|
498
|
+
* @returns {{changed: boolean}} 抛错表示多次尝试后仍冲突
|
|
499
|
+
*/
|
|
500
|
+
function mutatePatchStamped(label, transform, attempts = 3) {
|
|
501
|
+
for (let i = 0; i < attempts; i++) {
|
|
502
|
+
const { text, mtimeMs } = readPatchStamped();
|
|
503
|
+
const newText = transform(text);
|
|
504
|
+
if (newText === null) return { changed: false };
|
|
505
|
+
if (newText === text) return { changed: false };
|
|
506
|
+
if (safeWritePatchFile(newText, mtimeMs)) {
|
|
507
|
+
return { changed: true };
|
|
508
|
+
}
|
|
509
|
+
// mtime 已变:其他写入者抢先——重读重试
|
|
510
|
+
}
|
|
511
|
+
throw new Error(`家级补丁文件并发修改冲突(${label}),请稍后重试`);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* 计算块 lines 中最后一个内容行(非注释、非空白)的索引;整块皆注释/空行返回 -1。
|
|
516
|
+
*/
|
|
517
|
+
function contentEndIndex(lines) {
|
|
518
|
+
for (let k = lines.length - 1; k >= 0; k--) {
|
|
519
|
+
const t = lines[k].trim();
|
|
520
|
+
if (t !== '' && !t.startsWith('#')) return k;
|
|
521
|
+
}
|
|
522
|
+
return -1;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* 安全删除一个顶层块:块 lines 的尾部可能吸附了跟随其后、实际属于下一个块的
|
|
527
|
+
* 注释与空行(parsePatchYaml 把非顶格 "- " 行全部归入当前块)。删除时只删除到
|
|
528
|
+
* 最后一个内容行为止,把尾随注释保留为独立的注释块,避免误删下一块的注释头。
|
|
529
|
+
*/
|
|
530
|
+
function spliceBlockSafely(blocks, idx) {
|
|
531
|
+
const block = blocks[idx];
|
|
532
|
+
const endIdx = contentEndIndex(block.lines);
|
|
533
|
+
if (endIdx < 0) {
|
|
534
|
+
// 纯注释/空行块:直接整体删除
|
|
535
|
+
blocks.splice(idx, 1);
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
const retained = block.lines.slice(endIdx + 1);
|
|
539
|
+
const retainedComments = retained.filter((l) => l.trim() !== '');
|
|
540
|
+
if (retainedComments.length > 0) {
|
|
541
|
+
// 尾部吸附了注释行:保留它们(前面补一个空行分隔)
|
|
542
|
+
blocks[idx] = {
|
|
543
|
+
...block,
|
|
544
|
+
type: 'retained-comments',
|
|
545
|
+
lines: ['', ...retainedComments],
|
|
546
|
+
entries: []
|
|
547
|
+
};
|
|
548
|
+
} else {
|
|
549
|
+
// 尾部只有空行:整块删除即可
|
|
550
|
+
blocks.splice(idx, 1);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* 从前一个块的尾部剪掉本管理器自动登记的停用注释行('# ── dsh-plugin-manager 停用…')
|
|
556
|
+
* 及其前导空行——启用/卸载删除覆盖块时调用,避免残留孤儿注释。
|
|
557
|
+
*/
|
|
558
|
+
function stripAutoDisableComment(blocks, removedIdx) {
|
|
559
|
+
if (removedIdx <= 0) return;
|
|
560
|
+
const prev = blocks[removedIdx - 1];
|
|
561
|
+
if (!prev || !Array.isArray(prev.lines)) return;
|
|
562
|
+
const lines = prev.lines;
|
|
563
|
+
for (let k = lines.length - 1; k >= 0; k--) {
|
|
564
|
+
if (lines[k].includes('dsh-plugin-manager 停用')) {
|
|
565
|
+
let cut = k;
|
|
566
|
+
while (cut > 0 && lines[cut - 1].trim() === '') cut--;
|
|
567
|
+
prev.lines = lines.slice(0, cut);
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* 修改 Patch 中的条目启用/停用状态
|
|
575
|
+
*/
|
|
576
|
+
function patchToggleEntry(entryId, disabled) {
|
|
577
|
+
if (!existsSync(PATCH_PATH)) {
|
|
578
|
+
throw new Error(`找不到补丁文件: ${PATCH_PATH}`);
|
|
579
|
+
}
|
|
580
|
+
const transform = (originalText) => {
|
|
581
|
+
const { blocks } = parsePatchYaml(originalText);
|
|
582
|
+
|
|
583
|
+
let modified = false;
|
|
584
|
+
let inHomeInsert = false;
|
|
585
|
+
|
|
586
|
+
// 1. 查找是否存在于某个 insert 块中
|
|
587
|
+
for (const block of blocks) {
|
|
588
|
+
if (block.type === 'insert') {
|
|
589
|
+
for (const entry of block.entries) {
|
|
590
|
+
if (entry.id === entryId) {
|
|
591
|
+
inHomeInsert = true;
|
|
592
|
+
// 在 entry 的 lines 中调整 disabled
|
|
593
|
+
const entryLines = entry.lines;
|
|
594
|
+
let disabledLineIdx = -1;
|
|
595
|
+
for (let k = 0; k < entryLines.length; k++) {
|
|
596
|
+
if (/^\s+disabled:\s*(true|false)\s*$/.test(entryLines[k])) {
|
|
597
|
+
disabledLineIdx = k;
|
|
598
|
+
break;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const fieldIndent = (entry.indent || ' ') + ' '; // 默认为 6 空格
|
|
603
|
+
|
|
604
|
+
if (disabled) {
|
|
605
|
+
if (disabledLineIdx >= 0) {
|
|
606
|
+
entryLines[disabledLineIdx] = `${fieldIndent}disabled: true`;
|
|
607
|
+
} else {
|
|
608
|
+
// 插入到 name 后面或末尾
|
|
609
|
+
let insertAt = 1;
|
|
610
|
+
for (let k = 0; k < entryLines.length; k++) {
|
|
611
|
+
if (/^\s+name:/.test(entryLines[k])) {
|
|
612
|
+
insertAt = k + 1;
|
|
613
|
+
break;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
entryLines.splice(insertAt, 0, `${fieldIndent}disabled: true`);
|
|
617
|
+
}
|
|
618
|
+
} else {
|
|
619
|
+
// 启用:移除 disabled 行
|
|
620
|
+
if (disabledLineIdx >= 0) {
|
|
621
|
+
entryLines.splice(disabledLineIdx, 1);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// 将修改同步回 block.lines
|
|
626
|
+
const before = block.lines.slice(0, entry.startIdx);
|
|
627
|
+
const after = block.lines.slice(entry.endIdx + 1);
|
|
628
|
+
block.lines = [...before, ...entryLines, ...after];
|
|
629
|
+
modified = true;
|
|
630
|
+
break;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
if (modified) break;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// 2. 如果不在家级 insert 块中(说明是内置插件/底层插件)
|
|
638
|
+
if (!inHomeInsert) {
|
|
639
|
+
// 查找是否已有独立覆盖块
|
|
640
|
+
let overrideBlockIdx = -1;
|
|
641
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
642
|
+
const block = blocks[i];
|
|
643
|
+
if (block.type === 'override' && block.entries.some(e => e.id === entryId)) {
|
|
644
|
+
overrideBlockIdx = i;
|
|
645
|
+
break;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
if (disabled) {
|
|
650
|
+
// 停用内置插件:若已有覆盖块则确保 disabled: true,若无则追加新覆盖块
|
|
651
|
+
if (overrideBlockIdx >= 0) {
|
|
652
|
+
const block = blocks[overrideBlockIdx];
|
|
653
|
+
let hasDisabled = false;
|
|
654
|
+
for (let k = 0; k < block.lines.length; k++) {
|
|
655
|
+
if (/^\s+disabled:\s*(true|false)/.test(block.lines[k])) {
|
|
656
|
+
block.lines[k] = ' disabled: true';
|
|
657
|
+
hasDisabled = true;
|
|
658
|
+
break;
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
if (!hasDisabled) {
|
|
662
|
+
block.lines.push(' disabled: true');
|
|
663
|
+
}
|
|
664
|
+
} else {
|
|
665
|
+
// 末尾追加独立覆盖块
|
|
666
|
+
blocks.push({
|
|
667
|
+
type: 'override',
|
|
668
|
+
lines: [
|
|
669
|
+
'',
|
|
670
|
+
`# ── dsh-plugin-manager 停用(自动登记) ──`,
|
|
671
|
+
`- id: ${entryId}`,
|
|
672
|
+
` disabled: true`
|
|
673
|
+
],
|
|
674
|
+
entries: [{ id: entryId, disabled: true }]
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
modified = true;
|
|
678
|
+
} else {
|
|
679
|
+
// 启用内置插件:移除对应的独立覆盖块(连带清理自动登记注释)
|
|
680
|
+
if (overrideBlockIdx >= 0) {
|
|
681
|
+
stripAutoDisableComment(blocks, overrideBlockIdx);
|
|
682
|
+
spliceBlockSafely(blocks, overrideBlockIdx);
|
|
683
|
+
modified = true;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
if (modified) {
|
|
689
|
+
// 重组文本
|
|
690
|
+
const newLines = [];
|
|
691
|
+
for (const b of blocks) {
|
|
692
|
+
newLines.push(...b.lines);
|
|
693
|
+
}
|
|
694
|
+
return newLines.join('\n');
|
|
695
|
+
}
|
|
696
|
+
return null; // 无需变更
|
|
697
|
+
};
|
|
698
|
+
mutatePatchStamped(`toggle ${entryId}`, transform);
|
|
699
|
+
return true;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* 追加安装插件到 Patch 末尾
|
|
704
|
+
*/
|
|
705
|
+
function patchAddInstallEntry(pkgName, entryId) {
|
|
706
|
+
const transform = (originalText) => {
|
|
707
|
+
const addition = [
|
|
708
|
+
'',
|
|
709
|
+
`# ── dsh-plugin-manager 安装的插件(自动登记,可删除本块) ──`,
|
|
710
|
+
`- insert:`,
|
|
711
|
+
` - id: ${entryId}`,
|
|
712
|
+
` name: ${pkgName}`
|
|
713
|
+
].join('\n');
|
|
714
|
+
|
|
715
|
+
return originalText.trimEnd() + '\n' + addition + '\n';
|
|
716
|
+
};
|
|
717
|
+
mutatePatchStamped(`install ${pkgName}`, transform);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* 从 Patch 中移除插件条目
|
|
722
|
+
*/
|
|
723
|
+
function patchRemoveEntry(entryId) {
|
|
724
|
+
if (!existsSync(PATCH_PATH)) return;
|
|
725
|
+
const transform = (originalText) => {
|
|
726
|
+
const { blocks } = parsePatchYaml(originalText);
|
|
727
|
+
|
|
728
|
+
let modified = false;
|
|
729
|
+
|
|
730
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
731
|
+
const block = blocks[i];
|
|
732
|
+
if (block.type === 'insert') {
|
|
733
|
+
const matchIdx = block.entries.findIndex(e => e.id === entryId);
|
|
734
|
+
if (matchIdx >= 0) {
|
|
735
|
+
if (block.entries.length === 1) {
|
|
736
|
+
// 整个 insert 块只有这一个条目,整块安全删除(保留尾随注释)
|
|
737
|
+
spliceBlockSafely(blocks, i);
|
|
738
|
+
i--;
|
|
739
|
+
modified = true;
|
|
740
|
+
} else {
|
|
741
|
+
// 仅删除该 entry 的行
|
|
742
|
+
const entry = block.entries[matchIdx];
|
|
743
|
+
const before = block.lines.slice(0, entry.startIdx);
|
|
744
|
+
const after = block.lines.slice(entry.endIdx + 1);
|
|
745
|
+
block.lines = [...before, ...after];
|
|
746
|
+
block.entries.splice(matchIdx, 1);
|
|
747
|
+
modified = true;
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
} else if (block.type === 'override') {
|
|
751
|
+
if (block.entries.some(e => e.id === entryId)) {
|
|
752
|
+
stripAutoDisableComment(blocks, i);
|
|
753
|
+
spliceBlockSafely(blocks, i);
|
|
754
|
+
i--;
|
|
755
|
+
modified = true;
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
if (modified) {
|
|
761
|
+
const newLines = [];
|
|
762
|
+
for (const b of blocks) {
|
|
763
|
+
newLines.push(...b.lines);
|
|
764
|
+
}
|
|
765
|
+
return newLines.join('\n');
|
|
766
|
+
}
|
|
767
|
+
return null; // 未找到相关条目
|
|
768
|
+
};
|
|
769
|
+
mutatePatchStamped(`remove ${entryId}`, transform);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* =========================================================================
|
|
774
|
+
* 包管理操作(spawn 执行器)
|
|
775
|
+
* =========================================================================
|
|
776
|
+
*/
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* 寻找可用的 dsh CLI 路径或 npm CLI 路径
|
|
780
|
+
*/
|
|
781
|
+
function resolveCliBin() {
|
|
782
|
+
const dshArgvBin = process.argv[1];
|
|
783
|
+
if (dshArgvBin && existsSync(dshArgvBin) && dshArgvBin.endsWith('bin.js')) {
|
|
784
|
+
return { type: 'dsh', path: dshArgvBin };
|
|
785
|
+
}
|
|
786
|
+
const backendDshBin = join(BACKEND_DIR, 'dsh', 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js');
|
|
787
|
+
if (existsSync(backendDshBin)) {
|
|
788
|
+
return { type: 'dsh', path: backendDshBin };
|
|
789
|
+
}
|
|
790
|
+
if (existsSync(DEFAULT_NPM_CLI)) {
|
|
791
|
+
return { type: 'npm', path: DEFAULT_NPM_CLI };
|
|
792
|
+
}
|
|
793
|
+
return { type: 'npm', path: 'npm' };
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* 执行命令并捕获尾部输出
|
|
798
|
+
*/
|
|
799
|
+
function execCommand(command, args, cwd, timeoutMs = 300000) {
|
|
800
|
+
return new Promise((resolve, reject) => {
|
|
801
|
+
const logs = [];
|
|
802
|
+
const child = spawn(command, args, {
|
|
803
|
+
cwd,
|
|
804
|
+
env: { ...process.env },
|
|
805
|
+
windowsHide: true,
|
|
806
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
let timer = null;
|
|
810
|
+
if (timeoutMs > 0) {
|
|
811
|
+
timer = setTimeout(() => {
|
|
812
|
+
try { child.kill('SIGTERM'); } catch {}
|
|
813
|
+
reject(new Error(`命令执行超时 (${Math.round(timeoutMs / 1000)}s): ${command} ${args.join(' ')}`));
|
|
814
|
+
}, timeoutMs);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
const appendLog = (data) => {
|
|
818
|
+
const text = data.toString('utf8');
|
|
819
|
+
const lines = text.split(/\r?\n/);
|
|
820
|
+
for (const line of lines) {
|
|
821
|
+
if (line.trim()) {
|
|
822
|
+
logs.push(line);
|
|
823
|
+
if (logs.length > 200) logs.shift();
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
};
|
|
827
|
+
|
|
828
|
+
child.stdout.on('data', appendLog);
|
|
829
|
+
child.stderr.on('data', appendLog);
|
|
830
|
+
|
|
831
|
+
child.on('error', (err) => {
|
|
832
|
+
if (timer) clearTimeout(timer);
|
|
833
|
+
reject(err);
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
child.on('close', (code) => {
|
|
837
|
+
if (timer) clearTimeout(timer);
|
|
838
|
+
if (code === 0) {
|
|
839
|
+
resolve({ ok: true, log: logs.join('\n') });
|
|
840
|
+
} else {
|
|
841
|
+
reject(new Error(`命令退出异常 (code ${code}):\n${logs.slice(-30).join('\n')}`));
|
|
842
|
+
}
|
|
843
|
+
});
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/**
|
|
848
|
+
* 探测已安装的所有模块名称(用于搜索结果对照)
|
|
849
|
+
*/
|
|
850
|
+
function getInstalledModuleNames() {
|
|
851
|
+
const names = new Set();
|
|
852
|
+
|
|
853
|
+
// 1. web profile package.json 依赖
|
|
854
|
+
try {
|
|
855
|
+
if (existsSync(WEB_PKG_JSON_PATH)) {
|
|
856
|
+
const pkg = JSON.parse(readFileSync(WEB_PKG_JSON_PATH, 'utf8'));
|
|
857
|
+
if (pkg.dependencies) Object.keys(pkg.dependencies).forEach(k => names.add(k));
|
|
858
|
+
if (pkg.devDependencies) Object.keys(pkg.devDependencies).forEach(k => names.add(k));
|
|
859
|
+
}
|
|
860
|
+
} catch {}
|
|
861
|
+
|
|
862
|
+
// 2. cordis.patch.yml 中的 name 属性
|
|
863
|
+
try {
|
|
864
|
+
const { insertEntries } = getHomePatchEntries();
|
|
865
|
+
for (const entry of insertEntries.values()) {
|
|
866
|
+
if (entry.name && !entry.name.startsWith('file://')) {
|
|
867
|
+
names.add(entry.name);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
} catch {}
|
|
871
|
+
|
|
872
|
+
return names;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/**
|
|
876
|
+
* =========================================================================
|
|
877
|
+
* Cordis 插件入口
|
|
878
|
+
* =========================================================================
|
|
879
|
+
*/
|
|
880
|
+
export function apply(ctx) {
|
|
881
|
+
const logger = ctx.logger ? ctx.logger('plugin-manager') : console;
|
|
882
|
+
logger.info(`正在加载 dsh-plugin-manager 插件管理器 (v${PLUGIN_VERSION})...`);
|
|
883
|
+
|
|
884
|
+
// 挂载 Web 路由
|
|
885
|
+
const mountRoutes = (webCtx) => {
|
|
886
|
+
const webServer = webCtx.get('webServer');
|
|
887
|
+
if (!webServer || typeof webServer.register !== 'function') {
|
|
888
|
+
logger.warn('webServer 服务尚未就绪或缺少 register 方法');
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
const handler = async (req, res) => {
|
|
893
|
+
const urlObj = new URL(req.url, 'http://127.0.0.1');
|
|
894
|
+
const pathname = urlObj.pathname;
|
|
895
|
+
|
|
896
|
+
// 安全拦截校验
|
|
897
|
+
const sec = validateRequestSecurity(req);
|
|
898
|
+
if (!sec.ok) {
|
|
899
|
+
logger.warn(`安全拦截 (${pathname}): ${sec.message}`);
|
|
900
|
+
res.writeHead(sec.status, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
901
|
+
res.end(JSON.stringify({ ok: false, error: sec.message }));
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
const jsonRes = (statusCode, data) => {
|
|
906
|
+
res.writeHead(statusCode, {
|
|
907
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
908
|
+
'Cache-Control': 'no-store'
|
|
909
|
+
});
|
|
910
|
+
res.end(JSON.stringify(data));
|
|
911
|
+
};
|
|
912
|
+
|
|
913
|
+
try {
|
|
914
|
+
// 1. GET /plugin-manager/api/version
|
|
915
|
+
if (pathname === '/plugin-manager/api/version' && req.method === 'GET') {
|
|
916
|
+
return jsonRes(200, {
|
|
917
|
+
ok: true,
|
|
918
|
+
version: PLUGIN_VERSION,
|
|
919
|
+
hostDshVersion: HOST_DSH_VERSION,
|
|
920
|
+
compatibility: { dsh: '>=0.1.1-rc.2', tested: ['0.1.1-rc.2', '0.1.2-alpha.5', '0.1.3-alpha.2'] }
|
|
921
|
+
});
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// 2. GET /plugin-manager/api/status
|
|
925
|
+
if (pathname === '/plugin-manager/api/status' && req.method === 'GET') {
|
|
926
|
+
return jsonRes(200, {
|
|
927
|
+
ok: true,
|
|
928
|
+
busy: queueStatus.busy,
|
|
929
|
+
current: queueStatus.current,
|
|
930
|
+
last: queueStatus.last
|
|
931
|
+
});
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// 3. GET /plugin-manager/api/inventory
|
|
935
|
+
if (pathname === '/plugin-manager/api/inventory' && req.method === 'GET') {
|
|
936
|
+
const { insertEntries, overrideEntries } = getHomePatchEntries();
|
|
937
|
+
const manifest = readManifest();
|
|
938
|
+
const manifestEntryIds = new Set(manifest.map(m => m.entryId));
|
|
939
|
+
|
|
940
|
+
const list = [];
|
|
941
|
+
const seenCleanIds = new Set();
|
|
942
|
+
const cleanId = (id) => (id || '').replace(/^include:/, '');
|
|
943
|
+
|
|
944
|
+
// A. 遍历 ctx.loader.entries()
|
|
945
|
+
const loader = ctx.get('loader') || ctx.loader;
|
|
946
|
+
if (loader && typeof loader.entries === 'function') {
|
|
947
|
+
for (const entry of loader.entries()) {
|
|
948
|
+
if (entry.options?.group) continue;
|
|
949
|
+
const entryId = entry.id;
|
|
950
|
+
const cId = cleanId(entryId);
|
|
951
|
+
const moduleName = entry.options?.name || '';
|
|
952
|
+
const rawFiberPhase = entry.fiber?.state !== undefined ? FIBER_PHASE[entry.fiber.state] : null;
|
|
953
|
+
const isHomeInsert = insertEntries.has(entryId) || insertEntries.has(cId);
|
|
954
|
+
const override = overrideEntries.get(entryId) || overrideEntries.get(cId);
|
|
955
|
+
|
|
956
|
+
// 启用状态判定:若 home patch 针对内置项有 disabled: true,则判定为 false
|
|
957
|
+
let enabled = !entry.disabled;
|
|
958
|
+
if (override && override.disabled) {
|
|
959
|
+
enabled = false;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
seenCleanIds.add(cId);
|
|
963
|
+
seenCleanIds.add(entryId);
|
|
964
|
+
|
|
965
|
+
const source = isHomeInsert ? 'home' : 'builtin';
|
|
966
|
+
const cat = determineCategory(source, cId || entryId, moduleName, manifestEntryIds);
|
|
967
|
+
const funcCat = determineFuncCategory(moduleName);
|
|
968
|
+
|
|
969
|
+
list.push({
|
|
970
|
+
entryId: cId || entryId,
|
|
971
|
+
moduleName,
|
|
972
|
+
enabled,
|
|
973
|
+
phase: rawFiberPhase ?? 'unobserved',
|
|
974
|
+
source,
|
|
975
|
+
manageable: isHomeInsert,
|
|
976
|
+
category: cat,
|
|
977
|
+
funcCategory: funcCat
|
|
978
|
+
});
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
// B. 补充仅存在于 home patch 中但 loader 未加载(如 disabled: true)的条目
|
|
983
|
+
for (const [id, item] of insertEntries.entries()) {
|
|
984
|
+
if (!seenCleanIds.has(id) && !seenCleanIds.has(cleanId(id))) {
|
|
985
|
+
const cat = determineCategory('home', id, item.name, manifestEntryIds);
|
|
986
|
+
const funcCat = determineFuncCategory(item.name);
|
|
987
|
+
|
|
988
|
+
list.push({
|
|
989
|
+
entryId: id,
|
|
990
|
+
moduleName: item.name,
|
|
991
|
+
enabled: !item.disabled,
|
|
992
|
+
phase: item.disabled ? 'unobserved' : 'pending',
|
|
993
|
+
source: 'home',
|
|
994
|
+
manageable: true,
|
|
995
|
+
category: cat,
|
|
996
|
+
funcCategory: funcCat
|
|
997
|
+
});
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
return jsonRes(200, { ok: true, entries: list });
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
// 4. GET /plugin-manager/api/search?q=xxx
|
|
1005
|
+
if (pathname === '/plugin-manager/api/search' && req.method === 'GET') {
|
|
1006
|
+
const q = urlObj.searchParams.get('q') || '';
|
|
1007
|
+
const searchQuery = q.trim() ? `${q.trim()} dsh` : 'dsh-plugin';
|
|
1008
|
+
const installedNames = getInstalledModuleNames();
|
|
1009
|
+
|
|
1010
|
+
const fetchFromRegistry = async (registryUrl) => {
|
|
1011
|
+
const endpoint = `${registryUrl}/-/v1/search?text=${encodeURIComponent(searchQuery)}&size=25`;
|
|
1012
|
+
const response = await fetch(endpoint, {
|
|
1013
|
+
headers: { 'Accept': 'application/json' },
|
|
1014
|
+
signal: AbortSignal.timeout(8000)
|
|
1015
|
+
});
|
|
1016
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
1017
|
+
return response.json();
|
|
1018
|
+
};
|
|
1019
|
+
|
|
1020
|
+
let data = null;
|
|
1021
|
+
try {
|
|
1022
|
+
data = await fetchFromRegistry('https://registry.npmmirror.com');
|
|
1023
|
+
} catch (e1) {
|
|
1024
|
+
logger.warn(`npmmirror 搜索失败 (${e1.message}),正在降级回退至 npmjs.org...`);
|
|
1025
|
+
try {
|
|
1026
|
+
data = await fetchFromRegistry('https://registry.npmjs.org');
|
|
1027
|
+
} catch (e2) {
|
|
1028
|
+
return jsonRes(500, { ok: false, error: `NPM 搜索请求失败: ${e2.message}` });
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
const rawResults = data?.objects || [];
|
|
1033
|
+
const results = rawResults.map((item) => {
|
|
1034
|
+
const pkg = item.package || {};
|
|
1035
|
+
const isInstalled = installedNames.has(pkg.name);
|
|
1036
|
+
return {
|
|
1037
|
+
name: pkg.name,
|
|
1038
|
+
version: pkg.version,
|
|
1039
|
+
description: pkg.description || '',
|
|
1040
|
+
date: pkg.date || '',
|
|
1041
|
+
installed: isInstalled
|
|
1042
|
+
};
|
|
1043
|
+
});
|
|
1044
|
+
|
|
1045
|
+
return jsonRes(200, { ok: true, results });
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
// 5. POST /plugin-manager/api/install
|
|
1049
|
+
if (pathname === '/plugin-manager/api/install' && req.method === 'POST') {
|
|
1050
|
+
const body = await readJsonBody(req);
|
|
1051
|
+
const { name: pkgName, version } = body;
|
|
1052
|
+
if (!pkgName || typeof pkgName !== 'string') {
|
|
1053
|
+
return jsonRes(400, { ok: false, error: '缺少必需参数 name (npm 包名)' });
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
const pkgSpec = version ? `${pkgName}@${version}` : pkgName;
|
|
1057
|
+
const entryId = packageNameToEntryId(pkgName);
|
|
1058
|
+
|
|
1059
|
+
const result = await runInQueue('install', pkgName, async () => {
|
|
1060
|
+
logger.info(`开始安装插件: ${pkgSpec} 到 ${WEB_PROFILE_DIR}`);
|
|
1061
|
+
const cli = resolveCliBin();
|
|
1062
|
+
let installRes;
|
|
1063
|
+
|
|
1064
|
+
if (cli.type === 'dsh') {
|
|
1065
|
+
try {
|
|
1066
|
+
installRes = await execCommand(process.execPath, [cli.path, 'plugin', '--profile', 'web', 'add', pkgSpec], WEB_PROFILE_DIR);
|
|
1067
|
+
} catch (dshErr) {
|
|
1068
|
+
logger.warn(`dsh plugin add 执行失败 (${dshErr.message}),尝试 npm 安装兜底...`);
|
|
1069
|
+
installRes = await execCommand(process.execPath, [DEFAULT_NPM_CLI, 'install', pkgSpec, '--no-audit', '--no-fund', '--loglevel=error'], WEB_PROFILE_DIR);
|
|
1070
|
+
}
|
|
1071
|
+
} else {
|
|
1072
|
+
installRes = await execCommand(process.execPath, [cli.path, 'install', pkgSpec, '--no-audit', '--no-fund', '--loglevel=error'], WEB_PROFILE_DIR);
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
// 确认安装成功:检查 profiles/web/node_modules/<pkg>/package.json
|
|
1076
|
+
const targetPkgJsonPath = join(WEB_PROFILE_DIR, 'node_modules', ...pkgName.split('/'), 'package.json');
|
|
1077
|
+
if (!existsSync(targetPkgJsonPath)) {
|
|
1078
|
+
throw new Error(`安装完成后未找到 package.json: ${targetPkgJsonPath}`);
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
let installedVersion = version || '';
|
|
1082
|
+
try {
|
|
1083
|
+
const installedPkg = JSON.parse(readFileSync(targetPkgJsonPath, 'utf8'));
|
|
1084
|
+
installedVersion = installedPkg.version || installedVersion;
|
|
1085
|
+
} catch {}
|
|
1086
|
+
|
|
1087
|
+
// 更新 Patch 文件
|
|
1088
|
+
patchAddInstallEntry(pkgName, entryId);
|
|
1089
|
+
|
|
1090
|
+
// 更新 Manifest 记录
|
|
1091
|
+
const manifest = readManifest();
|
|
1092
|
+
const filtered = manifest.filter(m => m.entryId !== entryId);
|
|
1093
|
+
filtered.push({
|
|
1094
|
+
entryId,
|
|
1095
|
+
packageName: pkgName,
|
|
1096
|
+
version: installedVersion,
|
|
1097
|
+
installedAt: new Date().toISOString()
|
|
1098
|
+
});
|
|
1099
|
+
saveManifest(filtered);
|
|
1100
|
+
|
|
1101
|
+
logger.info(`插件 ${pkgSpec} 安装成功并已登记至 cordis.patch.yml`);
|
|
1102
|
+
return { ok: true, version: installedVersion, log: installRes.log };
|
|
1103
|
+
});
|
|
1104
|
+
|
|
1105
|
+
return jsonRes(200, result);
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
// 6. POST /plugin-manager/api/toggle
|
|
1109
|
+
if (pathname === '/plugin-manager/api/toggle' && req.method === 'POST') {
|
|
1110
|
+
const body = await readJsonBody(req);
|
|
1111
|
+
const { entryId, disabled } = body;
|
|
1112
|
+
if (!entryId || typeof disabled !== 'boolean') {
|
|
1113
|
+
return jsonRes(400, { ok: false, error: '参数非法 (需要 entryId 与 disabled)' });
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
logger.info(`执行插件启停: ${entryId} -> ${disabled ? '停用' : '启用'}`);
|
|
1117
|
+
patchToggleEntry(entryId, disabled);
|
|
1118
|
+
return jsonRes(200, { ok: true });
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
// 7. POST /plugin-manager/api/uninstall
|
|
1122
|
+
if (pathname === '/plugin-manager/api/uninstall' && req.method === 'POST') {
|
|
1123
|
+
const body = await readJsonBody(req);
|
|
1124
|
+
const { entryId } = body;
|
|
1125
|
+
if (!entryId) {
|
|
1126
|
+
return jsonRes(400, { ok: false, error: '缺少参数 entryId' });
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
const result = await runInQueue('uninstall', entryId, async () => {
|
|
1130
|
+
logger.info(`开始卸载插件: ${entryId}`);
|
|
1131
|
+
const { insertEntries } = getHomePatchEntries();
|
|
1132
|
+
const homeEntry = insertEntries.get(entryId);
|
|
1133
|
+
const manifest = readManifest();
|
|
1134
|
+
const manifestRecord = manifest.find(m => m.entryId === entryId);
|
|
1135
|
+
|
|
1136
|
+
// 1. 由插件管理器安装的 npm 包
|
|
1137
|
+
if (manifestRecord) {
|
|
1138
|
+
const pkgName = manifestRecord.packageName;
|
|
1139
|
+
patchRemoveEntry(entryId);
|
|
1140
|
+
|
|
1141
|
+
const cli = resolveCliBin();
|
|
1142
|
+
try {
|
|
1143
|
+
if (cli.type === 'dsh') {
|
|
1144
|
+
await execCommand(process.execPath, [cli.path, 'plugin', '--profile', 'web', 'remove', pkgName], WEB_PROFILE_DIR);
|
|
1145
|
+
} else {
|
|
1146
|
+
await execCommand(process.execPath, [cli.path, 'uninstall', pkgName, '--loglevel=error'], WEB_PROFILE_DIR);
|
|
1147
|
+
}
|
|
1148
|
+
} catch (e) {
|
|
1149
|
+
logger.warn(`卸载 npm 依赖警告 (${pkgName}): ${e.message}`);
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
const newManifest = manifest.filter(m => m.entryId !== entryId);
|
|
1153
|
+
saveManifest(newManifest);
|
|
1154
|
+
logger.info(`npm 插件 ${pkgName} (${entryId}) 已成功卸载`);
|
|
1155
|
+
return { ok: true };
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
// 2. 检查是否为本地自研 Junction 插件
|
|
1159
|
+
if (homeEntry && homeEntry.name && !homeEntry.name.startsWith('file://')) {
|
|
1160
|
+
const pkgName = homeEntry.name;
|
|
1161
|
+
const webJunction = join(DSH_HOME, 'profiles', 'web', 'node_modules', ...pkgName.split('/'));
|
|
1162
|
+
let isJunction = false;
|
|
1163
|
+
try {
|
|
1164
|
+
if (existsSync(webJunction)) {
|
|
1165
|
+
const stat = lstatSync(webJunction);
|
|
1166
|
+
isJunction = stat.isSymbolicLink() || (stat.isDirectory() && process.platform === 'win32');
|
|
1167
|
+
}
|
|
1168
|
+
} catch {}
|
|
1169
|
+
|
|
1170
|
+
if (isJunction) {
|
|
1171
|
+
patchRemoveEntry(entryId);
|
|
1172
|
+
// 删除三处 junction(rmSync 不会深入递归目标,安全)
|
|
1173
|
+
const j1 = join(DSH_HOME, 'node_modules', ...pkgName.split('/'));
|
|
1174
|
+
const j2 = join(DSH_HOME, 'profiles', 'node_modules', ...pkgName.split('/'));
|
|
1175
|
+
const j3 = join(DSH_HOME, 'profiles', 'web', 'node_modules', ...pkgName.split('/'));
|
|
1176
|
+
[j1, j2, j3].forEach(p => {
|
|
1177
|
+
try { if (existsSync(p)) rmSync(p, { recursive: true, force: true }); } catch {}
|
|
1178
|
+
});
|
|
1179
|
+
logger.info(`自研 Junction 插件 ${pkgName} (${entryId}) 接线已清除`);
|
|
1180
|
+
return { ok: true };
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
// 3. 本地 file:// 插件
|
|
1185
|
+
if (homeEntry && homeEntry.name && homeEntry.name.startsWith('file://')) {
|
|
1186
|
+
patchRemoveEntry(entryId);
|
|
1187
|
+
logger.info(`本地 file:// 插件 ${entryId} 行已从补丁中移除(源码保留)`);
|
|
1188
|
+
return { ok: true };
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
// 4. 家级 insert 登记的其他插件
|
|
1192
|
+
if (homeEntry) {
|
|
1193
|
+
patchRemoveEntry(entryId);
|
|
1194
|
+
return { ok: true };
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
// 5. 官方内置插件
|
|
1198
|
+
throw new Error('官方内置插件只能停用,不支持卸载。');
|
|
1199
|
+
});
|
|
1200
|
+
|
|
1201
|
+
return jsonRes(200, result);
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
// 默认 404
|
|
1205
|
+
return jsonRes(404, { ok: false, error: 'Endpoint Not Found' });
|
|
1206
|
+
} catch (err) {
|
|
1207
|
+
logger.error(`API 处理异常 (${pathname}):`, err);
|
|
1208
|
+
return jsonRes(500, { ok: false, error: err.message });
|
|
1209
|
+
}
|
|
1210
|
+
};
|
|
1211
|
+
|
|
1212
|
+
webCtx.effect(() => {
|
|
1213
|
+
return webServer.register({
|
|
1214
|
+
kind: 'prefix',
|
|
1215
|
+
path: '/plugin-manager',
|
|
1216
|
+
handler
|
|
1217
|
+
});
|
|
1218
|
+
}, 'dsh-plugin-manager: routes');
|
|
1219
|
+
};
|
|
1220
|
+
|
|
1221
|
+
// 依赖注入或兜底探测
|
|
1222
|
+
if (typeof ctx.inject === 'function') {
|
|
1223
|
+
ctx.inject(['webServer'], mountRoutes);
|
|
1224
|
+
} else {
|
|
1225
|
+
mountRoutes(ctx);
|
|
1226
|
+
}
|
|
1227
|
+
}
|