pi-web-ui 0.36.0 → 0.43.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/bin/pi-web-ui.mjs +89 -7
- package/dist/server/agent-service.js +99 -1
- package/dist/server/attachments.js +91 -40
- package/dist/server/bg-servers.js +5 -2
- package/dist/server/index.js +40 -4
- package/dist/server/mcp-bridge.js +268 -0
- package/dist/server/plugin-facilities.js +299 -0
- package/dist/server/plugin-updater.js +226 -0
- package/dist/server/plugins.js +448 -3
- package/dist/server/slash-commands.js +17 -1
- package/package.json +1 -1
- package/themes/md-preview.css +47 -0
- package/themes/white.css +47 -0
- package/web/dist/assets/{TerminalPanel-BxezvWth.js → TerminalPanel-BeTRtKaL.js} +1 -1
- package/web/dist/assets/index-BByVm30o.css +10 -0
- package/web/dist/assets/index-DPc38E4m.js +19 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-BP593LGC.js +0 -19
- package/web/dist/assets/index-DduTNQNx.css +0 -10
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP 工具桥 —— 把外部 Model Context Protocol(stdio)服务器暴露的工具接入
|
|
3
|
+
* pi 会话,让 AI 能调用真实的第三方工具(文件、数据库、GitHub…)。
|
|
4
|
+
*
|
|
5
|
+
* 约定(MCP 规范流式子集):
|
|
6
|
+
* - stdio 传输 = stdin/stdout 上换行分隔的 JSON-RPC 2.0(NDJSON),不依赖任何
|
|
7
|
+
* 第三方包;stderr 是自由日志通道。
|
|
8
|
+
* - 握手:initialize(带 protocolVersion)→ notifications/initialized →
|
|
9
|
+
* tools/list → tools/call。
|
|
10
|
+
* - 工具工具入会:本模块把每个远端工具适配成 PluginAgentTool,经
|
|
11
|
+
* pluginToolsProvider 走与插件工具完全相同的 customTools 管线。
|
|
12
|
+
*
|
|
13
|
+
* 配置:<PI_WEB_DATA_DIR>/mcp.json,形如
|
|
14
|
+
* { "servers": { "gitserv": { "command": "node", "args": ["mcp.js"], "cwd": "/x" } } }
|
|
15
|
+
*/
|
|
16
|
+
import { spawn } from "node:child_process";
|
|
17
|
+
import { readFileSync } from "node:fs";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
const PROTOCOL_VERSION = "2025-03-26"; // 广泛支持的工具版本
|
|
20
|
+
let rpcSeq = 0;
|
|
21
|
+
/**
|
|
22
|
+
* 单个 MCP 服务器的客户端:管理子进程、请求/响应按 id 关联、握手与工具调用。
|
|
23
|
+
* 线程模型:无需并发控制(MCP 允许乱序 + 我们按请求 id 匹配响应)。
|
|
24
|
+
*/
|
|
25
|
+
export class McpClient {
|
|
26
|
+
spec;
|
|
27
|
+
child = null;
|
|
28
|
+
buffer = "";
|
|
29
|
+
nextId = 1;
|
|
30
|
+
pending = new Map();
|
|
31
|
+
log;
|
|
32
|
+
name;
|
|
33
|
+
/** 已握手的工具列表(tools/list 结果缓存)。 */
|
|
34
|
+
tools = [];
|
|
35
|
+
shuttingDown = false;
|
|
36
|
+
constructor(name, spec, log) {
|
|
37
|
+
this.spec = spec;
|
|
38
|
+
this.name = name;
|
|
39
|
+
this.log = log ?? (() => { });
|
|
40
|
+
}
|
|
41
|
+
/** 启动子进程 + 握手 + 拉取工具列表。 */
|
|
42
|
+
async start(timeoutMs = 8000) {
|
|
43
|
+
if (this.child)
|
|
44
|
+
return;
|
|
45
|
+
const { command, args = [], cwd, env } = this.spec;
|
|
46
|
+
this.log(`[mcp:${this.name}] starting: ${command} ${args.join(" ")}`);
|
|
47
|
+
const child = spawn(command, args, {
|
|
48
|
+
cwd: cwd ?? undefined,
|
|
49
|
+
env: { ...process.env, ...env },
|
|
50
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
51
|
+
windowsHide: true,
|
|
52
|
+
});
|
|
53
|
+
this.child = child;
|
|
54
|
+
child.stderr.on("data", (d) => this.log(`[mcp:${this.name}] stderr:`, d.toString().trimEnd()));
|
|
55
|
+
child.on("error", (err) => this.rejectAll(new Error(`[mcp:${this.name}] spawn error: ${err.message}`)));
|
|
56
|
+
child.on("exit", (code, sig) => {
|
|
57
|
+
this.child = null;
|
|
58
|
+
if (!this.shuttingDown)
|
|
59
|
+
this.rejectAll(new Error(`[mcp:${this.name}] 进程退出 (${sig ?? code})`));
|
|
60
|
+
});
|
|
61
|
+
child.stdout.setEncoding("utf8");
|
|
62
|
+
child.stdout.on("data", (chunk) => this.onData(chunk));
|
|
63
|
+
// 握手
|
|
64
|
+
const handshake = await this.request("initialize", {
|
|
65
|
+
protocolVersion: this.spec.protocolVersion ?? PROTOCOL_VERSION,
|
|
66
|
+
capabilities: {},
|
|
67
|
+
clientInfo: { name: "pi-web-ui", version: "0.41.0" },
|
|
68
|
+
});
|
|
69
|
+
const version = handshake?.protocolVersion ?? this.spec.protocolVersion ?? PROTOCOL_VERSION;
|
|
70
|
+
// 通知 initialized(无 id 的 notification)
|
|
71
|
+
this.send({ jsonrpc: "2.0", method: "notifications/initialized" });
|
|
72
|
+
// 仍以协商协议版本调用 tools(多数服务器对新版本容忍,这里用协商结果)
|
|
73
|
+
void version;
|
|
74
|
+
const listed = (await this.request("tools/list", {}) ?? {});
|
|
75
|
+
this.tools = Array.isArray(listed.tools) ? listed.tools : [];
|
|
76
|
+
this.log(`[mcp:${this.name}] ready, ${this.tools.length} tools`);
|
|
77
|
+
}
|
|
78
|
+
/** 已发现工具。 */
|
|
79
|
+
getTools() {
|
|
80
|
+
return this.tools.map((t) => ({ ...t }));
|
|
81
|
+
}
|
|
82
|
+
/** 调用一个工具,返回结果文本(多 content 拼接为 JSON 字符串保真)。 */
|
|
83
|
+
async call(name, args, timeoutMs = 60000) {
|
|
84
|
+
const res = (await this.request("tools/call", { name, arguments: args }, timeoutMs));
|
|
85
|
+
if (res?.isError) {
|
|
86
|
+
const msg = (res.content ?? []).map((c) => c.text ?? "").join("\n").trim() || "MCP 工具错误";
|
|
87
|
+
throw new Error(msg);
|
|
88
|
+
}
|
|
89
|
+
// 结构化结果优先,其次文本内容。
|
|
90
|
+
if (res?.structuredContent !== undefined)
|
|
91
|
+
return res.structuredContent;
|
|
92
|
+
const text = (res.content ?? []).map((c) => c.text ?? "").filter((x) => x).join("\n");
|
|
93
|
+
return { content: text, isError: !!res.isError };
|
|
94
|
+
}
|
|
95
|
+
/** 关闭:kill 子进程,拒绝所有在途请求。 */
|
|
96
|
+
close() {
|
|
97
|
+
this.shuttingDown = true;
|
|
98
|
+
this.rejectAll(new Error("[mcp] client closed"));
|
|
99
|
+
if (this.child) {
|
|
100
|
+
try {
|
|
101
|
+
this.child.kill();
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
/* 已退出 */
|
|
105
|
+
}
|
|
106
|
+
this.child = null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// -- 内部 -------------------------------------------------------------
|
|
110
|
+
send(msg) {
|
|
111
|
+
const stdin = this.child?.stdin;
|
|
112
|
+
if (!stdin || !stdin.writable)
|
|
113
|
+
return;
|
|
114
|
+
stdin.write(JSON.stringify(msg) + "\n");
|
|
115
|
+
}
|
|
116
|
+
request(method, params, timeoutMs = 8000) {
|
|
117
|
+
const id = (rpcSeq++);
|
|
118
|
+
const outId = String(id);
|
|
119
|
+
return new Promise((resolve, reject) => {
|
|
120
|
+
const timer = setTimeout(() => {
|
|
121
|
+
this.pending.delete(outId);
|
|
122
|
+
reject(new Error(`[mcp:${this.name}] ${method} 超时 (${timeoutMs}ms)`));
|
|
123
|
+
}, timeoutMs);
|
|
124
|
+
this.pending.set(outId, { resolve, reject, timer });
|
|
125
|
+
this.send({ jsonrpc: "2.0", id: id, method, params });
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
onData(chunk) {
|
|
129
|
+
this.buffer += chunk;
|
|
130
|
+
let nl;
|
|
131
|
+
while ((nl = this.buffer.indexOf("\n")) >= 0) {
|
|
132
|
+
const line = this.buffer.slice(0, nl).trim();
|
|
133
|
+
this.buffer = this.buffer.slice(nl + 1);
|
|
134
|
+
if (!line)
|
|
135
|
+
continue;
|
|
136
|
+
let msg;
|
|
137
|
+
try {
|
|
138
|
+
msg = JSON.parse(line);
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
this.log(`[mcp:${this.name}] 非 JSON 行(忽略):`, line.slice(0, 120));
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
this.handleMessage(msg);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
handleMessage(msg) {
|
|
148
|
+
if (msg.id !== undefined) {
|
|
149
|
+
const pending = this.pending.get(String(msg.id));
|
|
150
|
+
if (!pending) {
|
|
151
|
+
this.log(`[mcp:${this.name}] 未知响应 id=${msg.id}`);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
this.pending.delete(String(msg.id));
|
|
155
|
+
clearTimeout(pending.timer);
|
|
156
|
+
if (msg.error)
|
|
157
|
+
pending.reject(new Error(`[mcp:${this.name}] ${msg.error.message ?? "MCP 错误"}`));
|
|
158
|
+
else
|
|
159
|
+
pending.resolve(msg.result);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
// 服务端主动通知(log / cancelled 等)——仅记录。
|
|
163
|
+
if (msg.method === "notifications/message") {
|
|
164
|
+
const p = msg.params;
|
|
165
|
+
if (p?.message)
|
|
166
|
+
this.log(`[mcp:${this.name}] ${p.level ?? "message"}:`, p.message);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
rejectAll(err) {
|
|
170
|
+
for (const [, p] of this.pending) {
|
|
171
|
+
clearTimeout(p.timer);
|
|
172
|
+
p.reject(err);
|
|
173
|
+
}
|
|
174
|
+
this.pending.clear();
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/** 读取 <dataDir>/mcp.json 里的服务器清单(尽力而为)。 */
|
|
178
|
+
export function readMcpConfig(dataDir) {
|
|
179
|
+
try {
|
|
180
|
+
const raw = JSON.parse(readFileSync(join(dataDir, "mcp.json"), "utf8"));
|
|
181
|
+
const servers = {};
|
|
182
|
+
for (const [name, s] of Object.entries(raw.servers ?? {})) {
|
|
183
|
+
if (!s || typeof s.command !== "string" || !s.command.trim())
|
|
184
|
+
continue;
|
|
185
|
+
servers[name] = {
|
|
186
|
+
command: s.command,
|
|
187
|
+
args: Array.isArray(s.args) ? s.args.map(String) : [],
|
|
188
|
+
cwd: typeof s.cwd === "string" ? s.cwd : undefined,
|
|
189
|
+
env: s.env && typeof s.env === "object" ? s.env : undefined,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
return { servers };
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
return { servers: {} };
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* 整个 MCP 管理器的工具适配:把每个 MCP 工具变成 PluginAgentTool。
|
|
200
|
+
* getAllToolsTool(name, callFn) 生成 execute → 转发到对应 McpClient.call。
|
|
201
|
+
*/
|
|
202
|
+
function adaptMcpTool(serverName, mcpTool, client) {
|
|
203
|
+
const name = sanitizeToolName(mcpTool.name);
|
|
204
|
+
return {
|
|
205
|
+
name,
|
|
206
|
+
label: `${serverName} · ${mcpTool.name}`,
|
|
207
|
+
description: mcpTool.description ?? `从 MCP 服务器「${serverName}」提供的工具 ${mcpTool.name}`,
|
|
208
|
+
parameters: mcpTool.inputSchema ?? {},
|
|
209
|
+
execute: async (_toolCallId, params, _signal) => {
|
|
210
|
+
return client.call(mcpTool.name, params ?? {});
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
/** 工具名必须是 [A-Za-z0-9_-]+(与插件工具同规则),MCP 可能含冒号/斜杠 — 归一化。 */
|
|
215
|
+
function sanitizeToolName(name) {
|
|
216
|
+
const cleaned = (name || "").replace(/[^A-Za-z0-9_-]/g, "_");
|
|
217
|
+
return cleaned || "mcp_tool";
|
|
218
|
+
}
|
|
219
|
+
/** MCP 服务器管理器:自管多服务器生命周期 + 聚合工具。 */
|
|
220
|
+
export class McpBridge {
|
|
221
|
+
dataDir;
|
|
222
|
+
log;
|
|
223
|
+
opts;
|
|
224
|
+
clients = [];
|
|
225
|
+
tools = [];
|
|
226
|
+
constructor(dataDir, log = () => { }, opts = {}) {
|
|
227
|
+
this.dataDir = dataDir;
|
|
228
|
+
this.log = log;
|
|
229
|
+
this.opts = opts;
|
|
230
|
+
}
|
|
231
|
+
/** 读取配置并启动全部服务器(顺序 fail-fast:单个失败记日志不拖垮其它)。 */
|
|
232
|
+
async load() {
|
|
233
|
+
const cfg = optsOverrideOrRead(this.opts.specOverride, this.dataDir);
|
|
234
|
+
await Promise.all(Object.entries(cfg.servers).map(async ([name, spec]) => {
|
|
235
|
+
try {
|
|
236
|
+
const client = new McpClient(name, spec, this.log);
|
|
237
|
+
await client.start();
|
|
238
|
+
this.clients.push(client);
|
|
239
|
+
for (const t of client.getTools())
|
|
240
|
+
this.tools.push(adaptMcpTool(name, t, client));
|
|
241
|
+
}
|
|
242
|
+
catch (err) {
|
|
243
|
+
this.log(`[mcp] 服务器「${name}」启动失败:`, err instanceof Error ? err.message : err);
|
|
244
|
+
}
|
|
245
|
+
}));
|
|
246
|
+
}
|
|
247
|
+
getTools() {
|
|
248
|
+
return this.tools;
|
|
249
|
+
}
|
|
250
|
+
hasServers() {
|
|
251
|
+
return this.clients.length > 0;
|
|
252
|
+
}
|
|
253
|
+
dispose() {
|
|
254
|
+
for (const c of this.clients)
|
|
255
|
+
c.close();
|
|
256
|
+
this.clients = [];
|
|
257
|
+
this.tools = [];
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
function optsOverrideOrRead(specOverride, dataDir) {
|
|
261
|
+
if (specOverride && specOverride.length > 0) {
|
|
262
|
+
const servers = {};
|
|
263
|
+
for (const o of specOverride)
|
|
264
|
+
servers[o.name] = o.spec;
|
|
265
|
+
return { servers };
|
|
266
|
+
}
|
|
267
|
+
return readMcpConfig(dataDir);
|
|
268
|
+
}
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 插件宿主设施:插件私有 KV 存储 + 加密 secrets,从 plugins.ts 抽出的纯设施。
|
|
3
|
+
*
|
|
4
|
+
* storage —— <pluginDir>/storage.json 单文件 JSON KV:
|
|
5
|
+
* - 全内存缓存、写入 tmp+rename 原子落盘(同 client-state.ts 的做法);
|
|
6
|
+
* - 供插件存非敏感配置(窗口布局、上次选中项…),替代各家手搓的
|
|
7
|
+
* read/write config.json 样板;
|
|
8
|
+
* - 生命周期跟插件目录绑定(uninstall 即删除),跨升级保留。
|
|
9
|
+
*
|
|
10
|
+
* secrets —— AES-256-GCM 加密的机密存储(密码/API key/token):
|
|
11
|
+
* - 密钥文件 <dataDir>/secrets.key(随机 32 字节,首次生成;chmod 0600 仅对
|
|
12
|
+
* POSIX 有意义,Windows 上 NTFS 权限继承用户目录默认 ACL);
|
|
13
|
+
* - 密文文件随插件目录 <pluginDir>/secrets.bin——拷到别的机器因无密钥解不开
|
|
14
|
+
* (fail closed);卸载插件即连密文一起删除;
|
|
15
|
+
* - 威胁模型:防「 casually 复制/查看文件」(混淆级保护)与「密文外泄」,
|
|
16
|
+
* 不能防同一用户账号下的完整进程妥协——本地个人工具的合理折衷。
|
|
17
|
+
*/
|
|
18
|
+
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
|
19
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
20
|
+
import { readFile as fspReadFile, readdir as fspReaddir, rm as fspRm, mkdir as fspMkdir, writeFile as fspWriteFile } from "node:fs/promises";
|
|
21
|
+
import { dirname, join, resolve } from "node:path";
|
|
22
|
+
import { createRequire } from "node:module";
|
|
23
|
+
import { spawnSync } from "node:child_process";
|
|
24
|
+
/** tmp+rename 原子写(错误由调用方隔离——插件设施的 IO 一律尽力而为)。 */
|
|
25
|
+
function atomicWrite(file, data) {
|
|
26
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
27
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
28
|
+
writeFileSync(tmp, data);
|
|
29
|
+
renameSync(tmp, file);
|
|
30
|
+
}
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// storage
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
/** 每插件的 JSON 文件 KV。所有方法同步(数据量小,避免并发写乱序)。 */
|
|
35
|
+
export class PluginStorage {
|
|
36
|
+
file;
|
|
37
|
+
cache;
|
|
38
|
+
constructor(file) {
|
|
39
|
+
this.file = file;
|
|
40
|
+
}
|
|
41
|
+
load() {
|
|
42
|
+
if (this.cache)
|
|
43
|
+
return this.cache;
|
|
44
|
+
try {
|
|
45
|
+
const parsed = JSON.parse(readFileSync(this.file, "utf8"));
|
|
46
|
+
this.cache = parsed && typeof parsed === "object" ? parsed : {};
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
this.cache = {}; // 不存在/损坏 = 空表(损坏不致命,重新积累)
|
|
50
|
+
}
|
|
51
|
+
return this.cache;
|
|
52
|
+
}
|
|
53
|
+
get(key, fallback) {
|
|
54
|
+
const v = this.load()[key];
|
|
55
|
+
return v === undefined ? fallback : v;
|
|
56
|
+
}
|
|
57
|
+
all() {
|
|
58
|
+
return { ...this.load() };
|
|
59
|
+
}
|
|
60
|
+
set(key, value) {
|
|
61
|
+
if (!key)
|
|
62
|
+
throw new Error("storage.set: key 不能为空");
|
|
63
|
+
const store = this.load();
|
|
64
|
+
store[key] = value;
|
|
65
|
+
try {
|
|
66
|
+
atomicWrite(this.file, JSON.stringify(store));
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
console.error(`[plugin-storage] 写入失败 (${this.file}):`, err);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
delete(key) {
|
|
73
|
+
const store = this.load();
|
|
74
|
+
if (!(key in store))
|
|
75
|
+
return;
|
|
76
|
+
delete store[key];
|
|
77
|
+
try {
|
|
78
|
+
atomicWrite(this.file, JSON.stringify(store));
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
console.error(`[plugin-storage] 写入失败 (${this.file}):`, err);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function seal(key, plaintext) {
|
|
86
|
+
const iv = randomBytes(12);
|
|
87
|
+
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
88
|
+
const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
89
|
+
return { iv: iv.toString("hex"), tag: cipher.getAuthTag().toString("hex"), ct: ct.toString("hex") };
|
|
90
|
+
}
|
|
91
|
+
function unseal(key, blob) {
|
|
92
|
+
const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(blob.iv, "hex"));
|
|
93
|
+
decipher.setAuthTag(Buffer.from(blob.tag, "hex"));
|
|
94
|
+
return Buffer.concat([decipher.update(Buffer.from(blob.ct, "hex")), decipher.final()]).toString("utf8");
|
|
95
|
+
}
|
|
96
|
+
/** 读或创建全局密钥文件(懒加载一次)。 */
|
|
97
|
+
function loadOrCreateKey(dataDir) {
|
|
98
|
+
const keyFile = join(dataDir, "secrets.key");
|
|
99
|
+
try {
|
|
100
|
+
if (existsSync(keyFile))
|
|
101
|
+
return Buffer.from(readFileSync(keyFile).toString("hex").trim(), "hex");
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
/* fallthrough → regenerate */
|
|
105
|
+
}
|
|
106
|
+
const key = randomBytes(32);
|
|
107
|
+
atomicWrite(keyFile, `${key.toString("hex")}\n`);
|
|
108
|
+
try {
|
|
109
|
+
chmodSync(keyFile, 0o600); // best-effort(win 无效,不抛错)
|
|
110
|
+
}
|
|
111
|
+
catch { }
|
|
112
|
+
return key;
|
|
113
|
+
}
|
|
114
|
+
/** 每插件的加密 KV。所有方法同步;任何读写失败都静默回退(机密丢失优于崩进程)。 */
|
|
115
|
+
export class PluginSecrets {
|
|
116
|
+
store;
|
|
117
|
+
file;
|
|
118
|
+
constructor(dataDir, pluginDir) {
|
|
119
|
+
this.file = join(pluginDir, "secrets.bin");
|
|
120
|
+
this.key = PluginSecrets.keyFor(dataDir);
|
|
121
|
+
}
|
|
122
|
+
key;
|
|
123
|
+
static keys = new Map();
|
|
124
|
+
/** 按 dataDir 惰性生成/复用密钥(同进程内共享,避免重复 IO)。 */
|
|
125
|
+
static keyFor(dataDir) {
|
|
126
|
+
let k = PluginSecrets.keys.get(dataDir);
|
|
127
|
+
if (!k) {
|
|
128
|
+
k = loadOrCreateKey(dataDir);
|
|
129
|
+
PluginSecrets.keys.set(dataDir, k);
|
|
130
|
+
}
|
|
131
|
+
return k;
|
|
132
|
+
}
|
|
133
|
+
load() {
|
|
134
|
+
if (this.store)
|
|
135
|
+
return this.store;
|
|
136
|
+
try {
|
|
137
|
+
const parsed = JSON.parse(readFileSync(this.file, "utf8"));
|
|
138
|
+
this.store =
|
|
139
|
+
parsed && parsed.v === 1 && parsed.items && typeof parsed.items === "object"
|
|
140
|
+
? parsed
|
|
141
|
+
: { v: 1, items: {} };
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
this.store = { v: 1, items: {} };
|
|
145
|
+
}
|
|
146
|
+
return this.store;
|
|
147
|
+
}
|
|
148
|
+
set(name, value) {
|
|
149
|
+
if (!name)
|
|
150
|
+
throw new Error("secrets.set: name 不能为空");
|
|
151
|
+
const s = this.load();
|
|
152
|
+
s.items[name] = seal(this.key, value);
|
|
153
|
+
try {
|
|
154
|
+
atomicWrite(this.file, JSON.stringify(s));
|
|
155
|
+
}
|
|
156
|
+
catch (err) {
|
|
157
|
+
console.error("[plugin-secrets] 写入失败:", err);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
get(name) {
|
|
161
|
+
const blob = this.load().items[name];
|
|
162
|
+
if (!blob)
|
|
163
|
+
return undefined;
|
|
164
|
+
try {
|
|
165
|
+
return unseal(this.key, blob);
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
return undefined; // 换机器 / 密钥轮换 → 解不开返回空(fail closed)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
has(name) {
|
|
172
|
+
return name in this.load().items;
|
|
173
|
+
}
|
|
174
|
+
delete(name) {
|
|
175
|
+
const s = this.load();
|
|
176
|
+
if (!(name in s.items))
|
|
177
|
+
return;
|
|
178
|
+
delete s.items[name];
|
|
179
|
+
try {
|
|
180
|
+
atomicWrite(this.file, JSON.stringify(s));
|
|
181
|
+
}
|
|
182
|
+
catch (err) {
|
|
183
|
+
console.error("[plugin-secrets] 写入失败:", err);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
list() {
|
|
187
|
+
return Object.keys(this.load().items);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// deps(宿主代插件自动补装运行时依赖)
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
const DEP_TIMEOUT_MS = 180_000; // 慢网安装兜底(含第一次拉取包元数据)
|
|
194
|
+
/** 从插件目录出发能否解析到这个模块(模拟插件自身 import() 的查找链)。 */
|
|
195
|
+
export function isDepAvailable(pluginDir, spec) {
|
|
196
|
+
try {
|
|
197
|
+
createRequire(join(pluginDir, "index.mjs")).resolve(spec);
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const depInstallLocks = new Map();
|
|
205
|
+
/** 确保依赖就绪:先逐个解析,缺了才一次性 `npm install` 补装,装完复查。
|
|
206
|
+
* 返回 true = 全部可用;false = 安装失败或超时。同目录并发调用单飞合并。
|
|
207
|
+
*
|
|
208
|
+
* 这是 webmail / db-client / vscode-editor 三家手搓 ensureXxxMod 的上收——
|
|
209
|
+
* 之前每家都自己拼 spawn 参数、自己处理 win32 的 npm.cmd、自己等 install 完成。 */
|
|
210
|
+
export function ensurePluginDeps(pluginDir, specs, onProgress) {
|
|
211
|
+
if (specs.length === 0)
|
|
212
|
+
return Promise.resolve(true);
|
|
213
|
+
const missing = specs.filter((s) => !isDepAvailable(pluginDir, s));
|
|
214
|
+
if (missing.length === 0)
|
|
215
|
+
return Promise.resolve(true);
|
|
216
|
+
const lockKey = join(pluginDir, missing.sort().join("|"));
|
|
217
|
+
const inflight = depInstallLocks.get(lockKey);
|
|
218
|
+
if (inflight)
|
|
219
|
+
return inflight;
|
|
220
|
+
const run = async () => {
|
|
221
|
+
// 无 package.json 时 npm 会沿目录树向上找最近一个,可能把依赖装进父目录——
|
|
222
|
+
// 先落一个最小 package.json 钉住安装位置。
|
|
223
|
+
if (!existsSync(join(pluginDir, "package.json"))) {
|
|
224
|
+
try {
|
|
225
|
+
atomicWrite(join(pluginDir, "package.json"), JSON.stringify({ name: "plugin-runtime-deps", private: true }, null, 2));
|
|
226
|
+
}
|
|
227
|
+
catch { }
|
|
228
|
+
}
|
|
229
|
+
onProgress?.(`正在安装依赖:${missing.join(", ")}…(首次约需几分钟)`);
|
|
230
|
+
// win32 的 npm 是 .cmd——spawnSync 直接跑会被 EINVAL 拒绝,必须走 shell;
|
|
231
|
+
// posix 不用 shell(路径不含空格假设成立,与宿主其它 spawn 一致)。
|
|
232
|
+
const res = spawnSync(process.platform === "win32" ? "npm.cmd" : "npm", ["install", "--no-audit", "--no-fund", ...missing], { cwd: pluginDir, timeout: DEP_TIMEOUT_MS, shell: process.platform === "win32", encoding: "utf8" });
|
|
233
|
+
if (res.error || res.status !== 0) {
|
|
234
|
+
console.error(`[plugin-deps] ${join(pluginDir)} npm install 失败:`, res.error ?? res.stderr?.slice(0, 500));
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
const stillMissing = specs.filter((s) => !isDepAvailable(pluginDir, s));
|
|
238
|
+
if (stillMissing.length) {
|
|
239
|
+
console.error(`[plugin-deps] 安装完成但仍缺:${stillMissing.join(", ")}`);
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
onProgress?.("依赖安装完成");
|
|
243
|
+
return true;
|
|
244
|
+
};
|
|
245
|
+
const p = run().finally(() => depInstallLocks.delete(lockKey));
|
|
246
|
+
depInstallLocks.set(lockKey, p);
|
|
247
|
+
return p;
|
|
248
|
+
}
|
|
249
|
+
export class WorkspaceFS {
|
|
250
|
+
root;
|
|
251
|
+
/** root 是活值 getter(返回当前工作区绝对路径),跟随 set_cwd。 */
|
|
252
|
+
constructor(root) {
|
|
253
|
+
this.root = root;
|
|
254
|
+
}
|
|
255
|
+
/** 相对路径 → 活根下的绝对路径;越界抛错。空串 = 根本身。 */
|
|
256
|
+
abs(rel) {
|
|
257
|
+
const rootDir = resolve(this.root());
|
|
258
|
+
const target = resolve(rootDir, typeof rel === "string" ? rel : "");
|
|
259
|
+
if (target !== rootDir && !target.startsWith(rootDir + sepOf())) {
|
|
260
|
+
throw new Error(`路径越界:${String(rel)}`);
|
|
261
|
+
}
|
|
262
|
+
return target;
|
|
263
|
+
}
|
|
264
|
+
/** 单层目录列表(浅层;深度遍历请插件自行递归)。 */
|
|
265
|
+
async list(relDir = "") {
|
|
266
|
+
try {
|
|
267
|
+
const dirents = await fspReaddir(this.abs(relDir), { withFileTypes: true });
|
|
268
|
+
return dirents
|
|
269
|
+
.slice(0, 2000)
|
|
270
|
+
.map((d) => ({ name: d.name, type: d.isDirectory() ? "dir" : "file" }));
|
|
271
|
+
}
|
|
272
|
+
catch (err) {
|
|
273
|
+
throw new Error(`读取目录失败:${err.message}`);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
/** 读文件(二进制)。声明为 async:路径校验失败以 rejected promise 表达
|
|
277
|
+
* (非 async 版本会同步 throw,破坏调用方 .catch/.rejects 契约)。 */
|
|
278
|
+
async read(relPath) {
|
|
279
|
+
return fspReadFile(this.abs(relPath));
|
|
280
|
+
}
|
|
281
|
+
/** 读文本(默认上限 512KB,超出截断——预览同款约定)。 */
|
|
282
|
+
async readText(relPath, maxBytes = 512 * 1024) {
|
|
283
|
+
const buf = await this.read(relPath);
|
|
284
|
+
return buf.subarray(0, maxBytes).toString("utf8");
|
|
285
|
+
}
|
|
286
|
+
/** 写文件(自动补父目录;注意相对路径锚定当前项目——切换 cwd 后写进新项目)。 */
|
|
287
|
+
async write(relPath, data) {
|
|
288
|
+
const target = this.abs(relPath);
|
|
289
|
+
await fspMkdir(dirname(target), { recursive: true });
|
|
290
|
+
await fspWriteFile(target, data);
|
|
291
|
+
}
|
|
292
|
+
/** 删除文件/目录(递归;只允许删工作区内的路径)。 */
|
|
293
|
+
async remove(relPath) {
|
|
294
|
+
await fspRm(this.abs(relPath), { recursive: true, force: false });
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
function sepOf() {
|
|
298
|
+
return process.platform === "win32" ? "\\" : "/";
|
|
299
|
+
}
|