pi-web-ui 0.85.0 → 0.86.2
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/CHANGELOG.md +124 -1
- package/bin/pi-web-ui.mjs +98 -8
- package/dist/server/agent-service.js +542 -16
- package/dist/server/client-state.js +99 -1
- package/dist/server/dsh/dsh-agent-service.js +239 -7
- package/dist/server/index.js +205 -3
- package/dist/server/managed.js +4 -0
- package/dist/server/plugin-catalog-sync.js +117 -0
- package/dist/server/plugin-catalog.js +67 -0
- package/dist/server/plugin-grants.js +281 -0
- package/dist/server/plugin-installer.js +238 -0
- package/dist/server/plugin-project.js +406 -0
- package/dist/server/plugins.js +390 -4
- package/dist/server/settings-service.js +34 -1
- package/dist/server/terminals.js +7 -2
- package/dist/server/update-check.js +100 -6
- package/dist/server/wait-subscription-scan.js +1 -1
- package/package.json +1 -1
- package/themes/dark-teal.css +209 -0
- package/web/dist/assets/TerminalPanel-BytY8dx7.js +6 -0
- package/web/dist/assets/index-CKoVyDkP.css +10 -0
- package/web/dist/assets/index-Dmwji4Cr.js +361 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/TerminalPanel-CNz4vxHW.js +0 -6
- package/web/dist/assets/index-BjODtvwn.css +0 -10
- package/web/dist/assets/index-DM-A59lk.js +0 -348
package/dist/server/index.js
CHANGED
|
@@ -41,6 +41,8 @@ import { launchOrigin, toServiceInfo } from "./launch-origin.js";
|
|
|
41
41
|
import { parseTabs, tabsRefusal } from "./tabs.js";
|
|
42
42
|
import { installPack, isKnownPack, listPacks, loadServerStrings, readPackFile, removePack, unloadServerStrings, } from "./locales.js";
|
|
43
43
|
import { PluginManager, resolvePluginClientFile, } from "./plugins.js";
|
|
44
|
+
import { PluginInstaller } from "./plugin-installer.js";
|
|
45
|
+
import { syncPluginCatalog } from "./plugin-catalog-sync.js";
|
|
44
46
|
import { McpBridge } from "./mcp-bridge.js";
|
|
45
47
|
/** 从 CLI 参数中取 flag 值:支持 --flag value 与 --flag=value 两种写法。
|
|
46
48
|
* 让 `node dist/server/index.js --host 0.0.0.0 --port 9000` 这类直接启动也能生效,
|
|
@@ -59,6 +61,19 @@ function cliFlag(name) {
|
|
|
59
61
|
const PORT = Number(cliFlag("--port") ?? process.env.PI_WEB_PORT ?? 8787);
|
|
60
62
|
const CWD = resolve(cliFlag("--cwd") ?? process.env.PI_WEB_CWD ?? process.cwd());
|
|
61
63
|
const DATA_DIR = resolve(cliFlag("--data-dir") ?? process.env.PI_WEB_DATA_DIR ?? join(homedir(), ".pi-web"));
|
|
64
|
+
// Dev-no-cache setting read without a ClientStateStore instance (the index.html
|
|
65
|
+
// route runs before any client attaches). Reads the same global settings blob.
|
|
66
|
+
function readDevNoCacheSetting() {
|
|
67
|
+
try {
|
|
68
|
+
const raw = readFileSync(join(DATA_DIR, "client-state.json"), "utf8");
|
|
69
|
+
const all = JSON.parse(raw);
|
|
70
|
+
const v = all["__settings__"]?.settings?.devNoCache;
|
|
71
|
+
return typeof v === "boolean" ? v : undefined;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
62
77
|
/** Bind address. Default is loopback ONLY — the service is a local personal
|
|
63
78
|
* tool and should not be reachable from the network unless explicitly asked
|
|
64
79
|
* (e.g. PI_WEB_HOST=0.0.0.0 for LAN access / Docker port mapping). */
|
|
@@ -105,6 +120,23 @@ function appVersion() {
|
|
|
105
120
|
}
|
|
106
121
|
return appVersionCache;
|
|
107
122
|
}
|
|
123
|
+
/** On-disk web-build id: the main JS bundle hash from the built index.html.
|
|
124
|
+
* Changes on every rebuild — stale pages compare and reload themselves.
|
|
125
|
+
* Declared near use (below webDist), not here: webDist is a const further
|
|
126
|
+
* down and calling this at module-init time would hit its dead zone. */
|
|
127
|
+
let buildIdCache = null;
|
|
128
|
+
function buildId() {
|
|
129
|
+
if (buildIdCache === null) {
|
|
130
|
+
try {
|
|
131
|
+
const html = readFileSync(join(webDistPath(), "index.html"), "utf8");
|
|
132
|
+
buildIdCache = html.match(/\/assets\/index-([A-Za-z0-9_-]+)\.js/)?.[1] ?? "";
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
buildIdCache = "";
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return buildIdCache;
|
|
139
|
+
}
|
|
108
140
|
// Root of the SDK default per-project session dirs — chat transcripts live in
|
|
109
141
|
// <SESSION_DIR_ROOT>/--<cwd>--/, shared with the pi CLI/TUI (getAgentDir
|
|
110
142
|
// honors PI_CODING_AGENT_DIR).
|
|
@@ -478,6 +510,10 @@ app.get("/plugins/:id/client/*", (req, res) => {
|
|
|
478
510
|
/** Set in the env of the replacement child spawned by a self-update restart. */
|
|
479
511
|
const RESTART_CHILD_ENV = "PI_WEB_RESTART_CHILD";
|
|
480
512
|
const webDist = join(pkgRoot, "web", "dist");
|
|
513
|
+
/** webDist accessor for buildId() (declared above webDist's const). */
|
|
514
|
+
function webDistPath() {
|
|
515
|
+
return webDist;
|
|
516
|
+
}
|
|
481
517
|
if (existsSync(webDist)) {
|
|
482
518
|
// gzip/deflate 响应压缩:前端 bundle ~1MB,局域网/反代场景传输量降到 ~1/4;
|
|
483
519
|
// 对 API JSON 同样生效,WS 升级不受影响
|
|
@@ -510,6 +546,17 @@ if (existsSync(webDist)) {
|
|
|
510
546
|
// Callback form: a failed stat here (npm i -g is mid-replacement of the
|
|
511
547
|
// package dir) responds 503 instead of crashing the request pipeline
|
|
512
548
|
// with an unhandled ENOENT stack trace.
|
|
549
|
+
// Dev caching (settings → message display → devNoCache): index.html pins
|
|
550
|
+
// hashed asset URLs — a cached copy keeps pointing at stale bundles
|
|
551
|
+
// after a rebuild+restart. Default follows the install: ON from source
|
|
552
|
+
// (.git next to the package root), OFF for installs. PI_WEB_DEV_CACHE=0/1
|
|
553
|
+
// overrides either way.
|
|
554
|
+
const devCache = process.env.PI_WEB_DEV_CACHE;
|
|
555
|
+
const fromSource = existsSync(join(pkgRoot, ".git"));
|
|
556
|
+
const envDefault = devCache !== undefined ? devCache !== "0" : fromSource;
|
|
557
|
+
const stored = readDevNoCacheSetting();
|
|
558
|
+
const noStore = stored ?? envDefault;
|
|
559
|
+
res.setHeader("Cache-Control", noStore ? "no-store" : "public, max-age=0");
|
|
513
560
|
res.sendFile(join(webDist, "index.html"), (err) => {
|
|
514
561
|
if (err && !res.headersSent) {
|
|
515
562
|
res.status(503).send("正在更新 pi-web-ui,请稍后刷新…");
|
|
@@ -634,6 +681,58 @@ loadServerStrings(DATA_DIR);
|
|
|
634
681
|
// Optional UI plugins (<dataDir>/plugins/<id>/): scanned on every client
|
|
635
682
|
// attach so freshly dropped plugins appear without a server restart.
|
|
636
683
|
const pluginMgr = new PluginManager(DATA_DIR, CWD, join(pkgRoot, "plugins", "catalog.json"));
|
|
684
|
+
// 插件作业(安装/更新/卸载)后台执行:不占用户终端、不打断设置面板(issue #152)。
|
|
685
|
+
// 真正干活的是 CLI(<pkgRoot>/bin/pi-web-ui.mjs),这里只做进程编排 + 进度转发。
|
|
686
|
+
const pluginInstaller = new PluginInstaller({ dataDir: DATA_DIR, pkgRoot, managed: MANAGED });
|
|
687
|
+
/** 插件目录/市场变化后统一收尾:重扫激活 + 重推 plugins 与 plugin_catalog。 */
|
|
688
|
+
async function reloadPluginsAndPush(lang) {
|
|
689
|
+
await pluginMgr.reload(lang);
|
|
690
|
+
await pluginMgr.pushToAll();
|
|
691
|
+
await pluginMgr.pushCatalog();
|
|
692
|
+
}
|
|
693
|
+
const pendingPathRequests = new Map();
|
|
694
|
+
/** 把授权表推给所有在线客户端(设置面板展示 + 撤销后刷新)。 */
|
|
695
|
+
function pushPluginGrants() {
|
|
696
|
+
const grants = pluginMgr.grants.list();
|
|
697
|
+
const payload = JSON.stringify({ type: "plugin_grants", grants });
|
|
698
|
+
for (const client of wss.clients) {
|
|
699
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
700
|
+
try {
|
|
701
|
+
client.send(payload);
|
|
702
|
+
}
|
|
703
|
+
catch {
|
|
704
|
+
/* 死连接:index.ts 自己会清理 */
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
pluginMgr.pathAccessRequester = (pluginId, dir, reason) => new Promise((resolve) => {
|
|
710
|
+
const id = randomUUID();
|
|
711
|
+
const timer = setTimeout(() => {
|
|
712
|
+
pendingPathRequests.delete(id);
|
|
713
|
+
resolve(false);
|
|
714
|
+
}, 120_000);
|
|
715
|
+
pendingPathRequests.set(id, { resolve, timer });
|
|
716
|
+
const payload = JSON.stringify({
|
|
717
|
+
type: "plugin_path_request",
|
|
718
|
+
id,
|
|
719
|
+
pluginId,
|
|
720
|
+
path: dir,
|
|
721
|
+
...(reason ? { reason } : {}),
|
|
722
|
+
});
|
|
723
|
+
for (const client of wss.clients) {
|
|
724
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
725
|
+
try {
|
|
726
|
+
client.send(payload);
|
|
727
|
+
}
|
|
728
|
+
catch {
|
|
729
|
+
/* 死连接 */
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
});
|
|
734
|
+
// 用户点了「允许」→ 授权表变了 → 立刻重推给所有在线客户端(设置面板「已授权目录」即时可见)。
|
|
735
|
+
pluginMgr.onGrantsChanged = () => pushPluginGrants();
|
|
637
736
|
// MCP 工具桥:读取 <dataDir>/mcp.json 启动外部 MCP 服务器(stdio),把它们的
|
|
638
737
|
// 工具并入与插件工具相同的 customTools 管线;单服务器失败不炸进程。
|
|
639
738
|
const mcpBridge = new McpBridge(DATA_DIR, (...a) => console.log("[mcp]", ...a));
|
|
@@ -664,7 +763,11 @@ service.pluginBgTasksProvider = () => pluginMgr.bgTasks();
|
|
|
664
763
|
service.pluginStopBgTask = (taskId) => pluginMgr.stopPluginBgTask(taskId);
|
|
665
764
|
// 插件宿主工作区实时跟随当前项目:任意客户端 set_cwd 成功后同步给
|
|
666
765
|
// PluginManager,编辑器等工作区跟随型插件随即切根(详见 plugins.ts notifyCwd)。
|
|
667
|
-
service.onClientCwdChanged = (cwd) =>
|
|
766
|
+
service.onClientCwdChanged = (cwd, roots) => {
|
|
767
|
+
pluginMgr.notifyCwd(cwd);
|
|
768
|
+
// 工作区根(宿主侧多根,issue #146):插件宿主的「工作区内」判定要跟着变。
|
|
769
|
+
pluginMgr.notifyWorkspaceRoots(roots);
|
|
770
|
+
};
|
|
668
771
|
// ---------------------------------------------------------------------------
|
|
669
772
|
// Self-update
|
|
670
773
|
// ---------------------------------------------------------------------------
|
|
@@ -919,6 +1022,11 @@ wss.on("connection", (ws) => {
|
|
|
919
1022
|
case "set_cwd":
|
|
920
1023
|
void cs.setCwd(msg.path);
|
|
921
1024
|
break;
|
|
1025
|
+
case "set_workspace_roots":
|
|
1026
|
+
// 宿主侧多根(issue #146):只改「哪些路径算工作区内」与右栏文件树的根,
|
|
1027
|
+
// 不动 cwd(AI 仍只在主 cwd 里干活)。
|
|
1028
|
+
void cs.setWorkspaceRoots(msg.roots);
|
|
1029
|
+
break;
|
|
922
1030
|
case "set_locale":
|
|
923
1031
|
// UI language report — per-client persist + lang-aware prompt
|
|
924
1032
|
// refresh (streaming-safe). Engine-agnostic via DispatchSession.
|
|
@@ -1010,8 +1118,14 @@ wss.on("connection", (ws) => {
|
|
|
1010
1118
|
break;
|
|
1011
1119
|
case "terminal_create": {
|
|
1012
1120
|
const tm = cs.getTerminalManager(msg.conversationId);
|
|
1013
|
-
if (tm)
|
|
1014
|
-
|
|
1121
|
+
if (tm) {
|
|
1122
|
+
// agentBash 透传:前端重建已退出的 AI 终端时保留其身份(issue #147);
|
|
1123
|
+
// 字段缺省(旧前端)时 create() 再从 history 继承。
|
|
1124
|
+
const createOpts = msg.locale !== undefined || msg.agentBash !== undefined
|
|
1125
|
+
? { locale: msg.locale, agentBash: msg.agentBash }
|
|
1126
|
+
: undefined;
|
|
1127
|
+
tm.create(msg.terminalId, msg.cwd, msg.cols, msg.rows, cs.getTerminalCwd(msg.conversationId), msg.title, createOpts);
|
|
1128
|
+
}
|
|
1015
1129
|
break;
|
|
1016
1130
|
}
|
|
1017
1131
|
case "terminal_input":
|
|
@@ -1080,6 +1194,8 @@ wss.on("connection", (ws) => {
|
|
|
1080
1194
|
goalModeEnabled: msg.goalModeEnabled,
|
|
1081
1195
|
thinkingWrap: msg.thinkingWrap,
|
|
1082
1196
|
toolsWrap: msg.toolsWrap,
|
|
1197
|
+
devNoCache: msg.devNoCache,
|
|
1198
|
+
autoReload: msg.autoReload,
|
|
1083
1199
|
skillsFullText: msg.skillsFullText,
|
|
1084
1200
|
visionBridgeEnabled: msg.visionBridgeEnabled,
|
|
1085
1201
|
visionBridgeModel: msg.visionBridgeModel,
|
|
@@ -1094,6 +1210,7 @@ wss.on("connection", (ws) => {
|
|
|
1094
1210
|
quickPhrases: msg.quickPhrases,
|
|
1095
1211
|
quickPhrasesEnabled: msg.quickPhrasesEnabled,
|
|
1096
1212
|
quickPhrasesSeeded: msg.quickPhrasesSeeded,
|
|
1213
|
+
uiLayout: msg.uiLayout,
|
|
1097
1214
|
});
|
|
1098
1215
|
break;
|
|
1099
1216
|
case "extensions_reload":
|
|
@@ -1135,6 +1252,87 @@ wss.on("connection", (ws) => {
|
|
|
1135
1252
|
}
|
|
1136
1253
|
break;
|
|
1137
1254
|
}
|
|
1255
|
+
// -- 插件后台作业(安装/更新/卸载,issue #152)----------------------------
|
|
1256
|
+
// 不再是「开一个可见终端 tab 并关掉设置面板」:作业在服务端后台跑,输出按行
|
|
1257
|
+
// 回给发起者,设置面板原就位显示。真正的执行者是 CLI(单一实现)。
|
|
1258
|
+
case "plugin_job": {
|
|
1259
|
+
const jobLang = () => cs?.getLang() ?? "en";
|
|
1260
|
+
const jobId = String(msg.jobId ?? "");
|
|
1261
|
+
const pluginId = String(msg.id ?? "");
|
|
1262
|
+
const started = pluginInstaller.start({
|
|
1263
|
+
jobId,
|
|
1264
|
+
action: msg.action,
|
|
1265
|
+
id: pluginId,
|
|
1266
|
+
source: msg.source,
|
|
1267
|
+
build: msg.build === true,
|
|
1268
|
+
}, {
|
|
1269
|
+
lang: jobLang,
|
|
1270
|
+
emit: (m) => send(m),
|
|
1271
|
+
done: async (ok, info) => {
|
|
1272
|
+
if (ok) {
|
|
1273
|
+
await reloadPluginsAndPush(jobLang);
|
|
1274
|
+
}
|
|
1275
|
+
else if (info.error) {
|
|
1276
|
+
cs?.emitNotice("error", `插件操作失败:${info.error}`, `Plugin operation failed: ${info.error}`);
|
|
1277
|
+
}
|
|
1278
|
+
},
|
|
1279
|
+
});
|
|
1280
|
+
if (!started.ok) {
|
|
1281
|
+
// 被拒(忙 / 托管实例 / 参数非法)也要回一条 done,让面板上的作业就地结束。
|
|
1282
|
+
send({
|
|
1283
|
+
type: "plugin_job",
|
|
1284
|
+
jobId,
|
|
1285
|
+
action: msg.action,
|
|
1286
|
+
pluginId,
|
|
1287
|
+
phase: "done",
|
|
1288
|
+
ok: false,
|
|
1289
|
+
error: started.error,
|
|
1290
|
+
output: "",
|
|
1291
|
+
});
|
|
1292
|
+
}
|
|
1293
|
+
break;
|
|
1294
|
+
}
|
|
1295
|
+
case "plugin_job_cancel":
|
|
1296
|
+
pluginInstaller.cancel(String(msg.jobId ?? ""));
|
|
1297
|
+
break;
|
|
1298
|
+
// -- 插件目录授权(issue #146)------------------------------------------
|
|
1299
|
+
case "plugin_path_response": {
|
|
1300
|
+
const pending = pendingPathRequests.get(String(msg.id ?? ""));
|
|
1301
|
+
if (pending) {
|
|
1302
|
+
clearTimeout(pending.timer);
|
|
1303
|
+
pendingPathRequests.delete(String(msg.id ?? ""));
|
|
1304
|
+
pending.resolve(msg.ok === true);
|
|
1305
|
+
}
|
|
1306
|
+
break;
|
|
1307
|
+
}
|
|
1308
|
+
case "plugin_path_revoke": {
|
|
1309
|
+
const removed = pluginMgr.grants.revoke(typeof msg.pluginId === "string" ? msg.pluginId : undefined, typeof msg.path === "string" ? msg.path : undefined);
|
|
1310
|
+
cs?.emitNotice("info", `已撤销 ${removed} 条插件目录授权`, `Revoked ${removed} plugin path grant(s)`);
|
|
1311
|
+
pushPluginGrants();
|
|
1312
|
+
break;
|
|
1313
|
+
}
|
|
1314
|
+
// -- 插件市场目录同步(issue #148)--------------------------------------
|
|
1315
|
+
case "plugin_catalog_sync": {
|
|
1316
|
+
const syncLang = () => cs?.getLang() ?? "en";
|
|
1317
|
+
const requestId = String(msg.requestId ?? "");
|
|
1318
|
+
void syncPluginCatalog(String(msg.source ?? ""), { install: msg.install === true, replace: msg.replace === true }, {
|
|
1319
|
+
customCatalogPath: pluginMgr.customCatalogPath,
|
|
1320
|
+
pluginsDir: join(DATA_DIR, "plugins"),
|
|
1321
|
+
installer: pluginInstaller,
|
|
1322
|
+
afterWrite: () => reloadPluginsAndPush(syncLang),
|
|
1323
|
+
lang: syncLang,
|
|
1324
|
+
}).then((r) => {
|
|
1325
|
+
send({
|
|
1326
|
+
type: "plugin_catalog_sync_result",
|
|
1327
|
+
requestId,
|
|
1328
|
+
ok: r.ok,
|
|
1329
|
+
...(r.error ? { error: r.error } : {}),
|
|
1330
|
+
entries: pluginMgr.catalog(),
|
|
1331
|
+
...(r.installed ? { installed: r.installed } : {}),
|
|
1332
|
+
});
|
|
1333
|
+
});
|
|
1334
|
+
break;
|
|
1335
|
+
}
|
|
1138
1336
|
case "dsh_patches_list":
|
|
1139
1337
|
void cs.listDshPatches?.();
|
|
1140
1338
|
break;
|
|
@@ -1194,6 +1392,7 @@ wss.on("connection", (ws) => {
|
|
|
1194
1392
|
// and the client used to learn ours from the update check —
|
|
1195
1393
|
// which a managed instance never runs.
|
|
1196
1394
|
appVersion: appVersion(),
|
|
1395
|
+
buildId: buildId(),
|
|
1197
1396
|
managed: MANAGED,
|
|
1198
1397
|
tabs: TABS ? [...TABS] : undefined,
|
|
1199
1398
|
service: SERVICE_INFO ?? undefined,
|
|
@@ -1215,6 +1414,8 @@ wss.on("connection", (ws) => {
|
|
|
1215
1414
|
// 让各插件向新接入的客户端推送自身初始状态(onAttach 钩子)——
|
|
1216
1415
|
// 插件不要依赖客户端挂载后自己拉(见 plugins.ts onAttach 注释)。
|
|
1217
1416
|
pluginMgr.notifyAttach(cid);
|
|
1417
|
+
// 插件目录授权表(设置面板展示 + 可撤销)
|
|
1418
|
+
send({ type: "plugin_grants", grants: pluginMgr.grants.list() });
|
|
1218
1419
|
// 插件命令可能在本客户端 attach 过程中才注册(首载竞态)——
|
|
1219
1420
|
// 重推一次目录,保证选择器完整。
|
|
1220
1421
|
service.applyPluginCommandCatalog();
|
|
@@ -1325,6 +1526,7 @@ async function shutdown() {
|
|
|
1325
1526
|
clearInterval(heartbeatTimer);
|
|
1326
1527
|
stopControl();
|
|
1327
1528
|
pluginMgr.dispose();
|
|
1529
|
+
pluginInstaller.dispose();
|
|
1328
1530
|
mcpBridge.dispose();
|
|
1329
1531
|
await service.disposeAll();
|
|
1330
1532
|
wss.close();
|
package/dist/server/managed.js
CHANGED
|
@@ -33,6 +33,10 @@ export const MANAGED_MESSAGES = [
|
|
|
33
33
|
"install_pi_agent",
|
|
34
34
|
/** Plugin market: fetches and installs a plugin from the network. */
|
|
35
35
|
"plugin_catalog_add",
|
|
36
|
+
/** Plugin install/update/uninstall run as a background job (issue #152). */
|
|
37
|
+
"plugin_job",
|
|
38
|
+
/** Catalog sync can install/update entries too (issue #148). */
|
|
39
|
+
"plugin_catalog_sync",
|
|
36
40
|
];
|
|
37
41
|
/**
|
|
38
42
|
* Whether this instance is managed from outside.
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* plugin-catalog-sync — 受支持的「插件市场目录同步」(issue #148)。
|
|
3
|
+
*
|
|
4
|
+
* 第三方插件想把自己的插件清单同步进宿主,本来只能:派发私有的浏览器事件
|
|
5
|
+
* `pi-web-ui:plugin-run-command` + 让用户在可见终端里看它跑命令。私有事件随时会变,
|
|
6
|
+
* 宿主的目录更新也没有回执。这里把它做成**受支持的一条路径**:
|
|
7
|
+
*
|
|
8
|
+
* host.reloadCatalog(source, { install, replace }) (插件侧,浏览器)
|
|
9
|
+
* → plugin_catalog_sync (协议)
|
|
10
|
+
* → 本文件:读文档 → 校验 → 原子写盘 → 可选安装 → 重载 + 重推 → 回执
|
|
11
|
+
*
|
|
12
|
+
* 三条纪律(对齐 issue 的验收标准):
|
|
13
|
+
* 1. 校验与市场「添加到列表」同源(plugin-catalog.ts 的 toEntry)——不合法条目丢弃,
|
|
14
|
+
* 形状不对 / 读不到 / 解析失败时**一个字节都不写盘**(旧目录保持有效);
|
|
15
|
+
* 2. 安装走正常安装器(PluginInstaller,同 CLI + 同一把锁),失败逐条回报,
|
|
16
|
+
* 不因为一条坏条目就停掉整批;
|
|
17
|
+
* 3. 回执是结构化的(ok/error/entries/installed),不靠 DOM 事件或控制台文字。
|
|
18
|
+
*/
|
|
19
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
20
|
+
import { isAbsolute, join } from "node:path";
|
|
21
|
+
import { pick } from "./i18n.js";
|
|
22
|
+
import { normalizeSyncPayload, writeCustomCatalog } from "./plugin-catalog.js";
|
|
23
|
+
/**
|
|
24
|
+
* 读同步文档:`http(s)://` 走网络,其余当本地文件路径(须为绝对路径)。
|
|
25
|
+
* 只读文本,JSON 解析交给调用方(解析失败也走同一条「不写盘」的路径)。
|
|
26
|
+
*/
|
|
27
|
+
async function readDocument(source, deps, lang) {
|
|
28
|
+
const l = lang();
|
|
29
|
+
const maxBytes = Math.max(1024, Number(deps.maxBytes ?? 1024 * 1024));
|
|
30
|
+
if (/^https?:\/\//i.test(source)) {
|
|
31
|
+
let res;
|
|
32
|
+
try {
|
|
33
|
+
res = await fetch(source, {
|
|
34
|
+
redirect: "follow",
|
|
35
|
+
signal: AbortSignal.timeout(Math.max(1000, Number(deps.fetchTimeoutMs ?? 30_000))),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
return {
|
|
40
|
+
error: pick(l, `拉取目录失败:${err?.message ?? err}`, `Failed to fetch the catalog: ${err?.message ?? err}`, "plugincatalog.sync.fetch.failed", { reason: String(err?.message ?? err) }),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
if (!res.ok)
|
|
44
|
+
return {
|
|
45
|
+
error: pick(l, `拉取目录失败:HTTP ${res.status}`, `Failed to fetch the catalog: HTTP ${res.status}`, "plugincatalog.sync.http", { status: String(res.status) }),
|
|
46
|
+
};
|
|
47
|
+
const text = await res.text();
|
|
48
|
+
if (text.length > maxBytes)
|
|
49
|
+
return {
|
|
50
|
+
error: pick(l, `目录文档过大(> ${Math.round(maxBytes / 1024)} KB)`, `Catalog document too large (> ${Math.round(maxBytes / 1024)} KB)`, "plugincatalog.sync.too.large", { kb: String(Math.round(maxBytes / 1024)) }),
|
|
51
|
+
};
|
|
52
|
+
return { text };
|
|
53
|
+
}
|
|
54
|
+
const p = source.trim();
|
|
55
|
+
if (!isAbsolute(p))
|
|
56
|
+
return {
|
|
57
|
+
error: pick(l, "来源需为 http(s) URL 或本地文件的绝对路径", "Source must be an http(s) URL or an absolute local file path", "plugincatalog.sync.source.invalid"),
|
|
58
|
+
};
|
|
59
|
+
try {
|
|
60
|
+
const text = readFileSync(p, "utf8");
|
|
61
|
+
if (text.length > maxBytes)
|
|
62
|
+
return {
|
|
63
|
+
error: pick(l, `目录文档过大(> ${Math.round(maxBytes / 1024)} KB)`, `Catalog document too large (> ${Math.round(maxBytes / 1024)} KB)`, "plugincatalog.sync.too.large", { kb: String(Math.round(maxBytes / 1024)) }),
|
|
64
|
+
};
|
|
65
|
+
return { text };
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
return {
|
|
69
|
+
error: pick(l, `读取目录文件失败:${err?.message ?? err}`, `Failed to read the catalog file: ${err?.message ?? err}`, "plugincatalog.sync.read.failed", { reason: String(err?.message ?? err) }),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* 同步一次目录:读 → 校验 → 原子写 → 可选安装 → 重载 + 重推。
|
|
75
|
+
* 任何一步失败都以 `{ ok:false, error }` 返回(不抛),由协议层原样回给调用者。
|
|
76
|
+
*/
|
|
77
|
+
export async function syncPluginCatalog(source, opts, deps) {
|
|
78
|
+
const lang = deps.lang ?? (() => "en");
|
|
79
|
+
const l = lang();
|
|
80
|
+
const src = String(source ?? "").trim();
|
|
81
|
+
if (!src)
|
|
82
|
+
return {
|
|
83
|
+
ok: false,
|
|
84
|
+
error: pick(l, "缺少目录来源", "Missing catalog source", "plugincatalog.sync.source.missing"),
|
|
85
|
+
};
|
|
86
|
+
const doc = await readDocument(src, deps, lang);
|
|
87
|
+
if ("error" in doc)
|
|
88
|
+
return { ok: false, error: doc.error };
|
|
89
|
+
let raw;
|
|
90
|
+
try {
|
|
91
|
+
raw = JSON.parse(doc.text);
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
return {
|
|
95
|
+
ok: false,
|
|
96
|
+
error: pick(l, `目录 JSON 解析失败:${err?.message ?? err}`, `Catalog JSON is not valid JSON: ${err?.message ?? err}`, "plugincatalog.sync.parse.failed", { reason: String(err?.message ?? err) }),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
const payload = normalizeSyncPayload(raw, lang);
|
|
100
|
+
if ("error" in payload)
|
|
101
|
+
return { ok: false, error: payload.error };
|
|
102
|
+
// 到这里才动磁盘:校验失败的文档绝不覆盖一份有效目录。
|
|
103
|
+
writeCustomCatalog(deps.customCatalogPath, payload.entries, opts.replace === true);
|
|
104
|
+
await deps.afterWrite();
|
|
105
|
+
let installed;
|
|
106
|
+
if (opts.install === true && payload.entries.length) {
|
|
107
|
+
installed = [];
|
|
108
|
+
for (const e of payload.entries) {
|
|
109
|
+
const action = existsSync(join(deps.pluginsDir, e.id)) ? "update" : "install";
|
|
110
|
+
const res = await deps.installer.run({ jobId: `catalog-sync:${e.id}`, action, id: e.id, source: e.source }, { lang });
|
|
111
|
+
installed.push({ id: e.id, ok: res.ok, ...(res.error ? { error: res.error } : {}) });
|
|
112
|
+
}
|
|
113
|
+
// 安装改变了 <dataDir>/plugins —— 再重载/重推一次,让新插件与前端清单对齐。
|
|
114
|
+
await deps.afterWrite();
|
|
115
|
+
}
|
|
116
|
+
return { ok: true, installed };
|
|
117
|
+
}
|
|
@@ -37,6 +37,8 @@ function isValidSource(source) {
|
|
|
37
37
|
return false;
|
|
38
38
|
return true;
|
|
39
39
|
}
|
|
40
|
+
/** 导出给服务端其它模块(后台作业的安装源校验、目录同步),单一事实源。 */
|
|
41
|
+
export { isValidSource };
|
|
40
42
|
/** 推导默认 id:与 CLI(bin/pi-web-ui.mjs)的规则对齐 —— 子路径末段 > 仓库名
|
|
41
43
|
* > 来源末段;非法字符替换为 -,两端去 -;空则 "plugin"。显式 raw(含合法
|
|
42
44
|
* id 校验)优先。返回的 id 不保证通过 ID_RE(Cyrillic 等),调用方再校验。 */
|
|
@@ -161,3 +163,68 @@ export function removeCustomEntry(customPath, id) {
|
|
|
161
163
|
atomicWrite(customPath, { entries: next });
|
|
162
164
|
return true;
|
|
163
165
|
}
|
|
166
|
+
/**
|
|
167
|
+
* 把同步文档规范化为自定义条目列表(纯函数,不碰磁盘)。
|
|
168
|
+
*
|
|
169
|
+
* 文档形状:JSON 数组,或 `{ entries: [...] }`(与本文件自身的磁盘格式同形)。
|
|
170
|
+
* 每个条目走 `toEntry` —— 与市场“添加到列表”**同一套校验**(id 字符集、source 必须
|
|
171
|
+
* 是远程 owner/repo[/subdir][#ref]、字段 trimmed + 长度上限),非法条目丢弃并计数;
|
|
172
|
+
* 整份文档形状不对则直接报错,由调用方原样回给调用者(绝不写盘)。
|
|
173
|
+
*/
|
|
174
|
+
export function normalizeSyncPayload(raw, lang) {
|
|
175
|
+
const l = lang?.() ?? "en";
|
|
176
|
+
const list = Array.isArray(raw)
|
|
177
|
+
? raw
|
|
178
|
+
: raw && typeof raw === "object" && Array.isArray(raw.entries)
|
|
179
|
+
? raw.entries
|
|
180
|
+
: null;
|
|
181
|
+
if (!list)
|
|
182
|
+
return {
|
|
183
|
+
error: pick(l, '目录 JSON 需为数组,或 {"entries": [...]} 形状', 'Catalog JSON must be an array or the {"entries": [...]} shape', "plugincatalog.sync.shape"),
|
|
184
|
+
};
|
|
185
|
+
const entries = [];
|
|
186
|
+
let skipped = 0;
|
|
187
|
+
for (const it of list) {
|
|
188
|
+
const e = it && typeof it === "object" ? toEntry(it, false) : null;
|
|
189
|
+
if (e)
|
|
190
|
+
entries.push(e);
|
|
191
|
+
else
|
|
192
|
+
skipped += 1;
|
|
193
|
+
}
|
|
194
|
+
return { entries, skipped };
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* 原子写入用户自定义列表。
|
|
198
|
+
*
|
|
199
|
+
* `replace=true` 整体替换(文档即真相);`false`(默认)按 id upsert:文档里出现的
|
|
200
|
+
* id 覆盖同名旧条目,文档没提的旧条目**保留** —— 这样插件只推送增量也能用。
|
|
201
|
+
* 写入内容只包含本文件认识的白名单字段(id/source/name/description/descriptionEn/
|
|
202
|
+
* icon/homepage),远端文档里塞的其他键不会落到磁盘上。
|
|
203
|
+
*
|
|
204
|
+
* 返回写盘后的自定义条目数(合并列表由 PluginManager.catalog() 重读得出)。
|
|
205
|
+
*/
|
|
206
|
+
export function writeCustomCatalog(customPath, incoming, replace) {
|
|
207
|
+
const byId = new Map();
|
|
208
|
+
if (!replace) {
|
|
209
|
+
const raw = readJsonSafe(customPath, {});
|
|
210
|
+
const existing = Array.isArray(raw.entries) ? raw.entries : [];
|
|
211
|
+
for (const it of existing) {
|
|
212
|
+
const e = it && typeof it === "object" ? toEntry(it, false) : null;
|
|
213
|
+
if (e)
|
|
214
|
+
byId.set(e.id, e);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
for (const e of incoming)
|
|
218
|
+
byId.set(e.id, e);
|
|
219
|
+
const entries = [...byId.values()].map((e) => ({
|
|
220
|
+
id: e.id,
|
|
221
|
+
source: e.source,
|
|
222
|
+
name: e.name,
|
|
223
|
+
...(e.description ? { description: e.description } : {}),
|
|
224
|
+
...(e.descriptionEn ? { descriptionEn: e.descriptionEn } : {}),
|
|
225
|
+
...(e.icon ? { icon: e.icon } : {}),
|
|
226
|
+
...(e.homepage ? { homepage: e.homepage } : {}),
|
|
227
|
+
}));
|
|
228
|
+
atomicWrite(customPath, { entries });
|
|
229
|
+
return entries.length;
|
|
230
|
+
}
|