@youweichen/pi-web-ui 0.45.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.
Files changed (140) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +45 -0
  3. package/bin/pi-web-ui.mjs +1818 -0
  4. package/deploy/com.xingshuyin.pi-web-ui.plist +48 -0
  5. package/deploy/nginx-subpath.conf +88 -0
  6. package/deploy/pi-web-ui-task.xml +71 -0
  7. package/deploy/pi-web-ui.service +31 -0
  8. package/dist/server/agent-service.js +2975 -0
  9. package/dist/server/attachments.js +672 -0
  10. package/dist/server/bg-servers.js +150 -0
  11. package/dist/server/client-state.js +234 -0
  12. package/dist/server/control-socket.js +177 -0
  13. package/dist/server/ensure-bash.js +80 -0
  14. package/dist/server/files-service.js +709 -0
  15. package/dist/server/goal-service.js +869 -0
  16. package/dist/server/index.js +879 -0
  17. package/dist/server/mcp-bridge.js +268 -0
  18. package/dist/server/model-admin.js +821 -0
  19. package/dist/server/patch-node-pty.js +80 -0
  20. package/dist/server/plugin-facilities.js +299 -0
  21. package/dist/server/plugin-updater.js +226 -0
  22. package/dist/server/plugins.js +893 -0
  23. package/dist/server/process-utils.js +112 -0
  24. package/dist/server/protocol-version.js +11 -0
  25. package/dist/server/protocol.js +6 -0
  26. package/dist/server/scm.js +298 -0
  27. package/dist/server/serialize.js +172 -0
  28. package/dist/server/settings-service.js +395 -0
  29. package/dist/server/slash-commands.js +263 -0
  30. package/dist/server/terminals.js +1372 -0
  31. package/dist/server/text-sniff.js +268 -0
  32. package/dist/server/uploads.js +107 -0
  33. package/dist/server/vision-bridge.js +131 -0
  34. package/dist/server/webui-context.js +222 -0
  35. package/extensions/webui.ts +192 -0
  36. package/package.json +99 -0
  37. package/web/dist/assets/TerminalPanel-6GBZ9nXN.css +32 -0
  38. package/web/dist/assets/TerminalPanel-D4AktsEn.js +2 -0
  39. package/web/dist/assets/abnfDiagram-VCTEODGH-Ct5StJwt.js +1 -0
  40. package/web/dist/assets/arc-pkuxEN8A.js +1 -0
  41. package/web/dist/assets/architectureDiagram-5GKGNRK7-C6P6yn4y.js +36 -0
  42. package/web/dist/assets/blockDiagram-I7D4REHJ-BmgfH1jt.js +129 -0
  43. package/web/dist/assets/c4Diagram-7LVT6UL2-CgmwTFsK.js +38 -0
  44. package/web/dist/assets/channel-0Y__6sq2.js +1 -0
  45. package/web/dist/assets/chunk-2Q5K7J3B-DX_NqAiH.js +1 -0
  46. package/web/dist/assets/chunk-5VM5RSS4-gLeuQuYv.js +15 -0
  47. package/web/dist/assets/chunk-F27PBJKO-D4WX4mhI.js +1 -0
  48. package/web/dist/assets/chunk-IMKFNOWR-DBpDxGQD.js +231 -0
  49. package/web/dist/assets/chunk-JWPE2WC7-DIQ8iZN5.js +1 -0
  50. package/web/dist/assets/chunk-POPQ4Y6H-F_YTNoGz.js +1 -0
  51. package/web/dist/assets/chunk-SVP7TREG-CmDIvX-r.js +88 -0
  52. package/web/dist/assets/chunk-TICWLB2K-BQUzHQyS.js +206 -0
  53. package/web/dist/assets/chunk-XXDRQBXY-DqR9iffH.js +1 -0
  54. package/web/dist/assets/classDiagram-ZZMXUADV-BEfGIpzu.js +1 -0
  55. package/web/dist/assets/classDiagram-v2-VYDZK3BY-BEfGIpzu.js +1 -0
  56. package/web/dist/assets/cose-bilkent-JH36ORCC-Dl67W4Dx.js +1 -0
  57. package/web/dist/assets/cynefin-OW5HDTMX-fWdqaCpX.js +178 -0
  58. package/web/dist/assets/cynefinDiagram-5FMLGOSQ-CSUAXbaP.js +62 -0
  59. package/web/dist/assets/cytoscape.esm-Bch-eiPH.js +331 -0
  60. package/web/dist/assets/dagre-GXQ25YYZ-CqUVwu1X.js +4 -0
  61. package/web/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
  62. package/web/dist/assets/diagram-S7CK7UJ4-bRce-WYV.js +30 -0
  63. package/web/dist/assets/diagram-UQ7AKVKN-CiqPtPBE.js +41 -0
  64. package/web/dist/assets/diagram-VSXAHHWV-CbEA05LX.js +3 -0
  65. package/web/dist/assets/diagram-VX7I27RA-CRcY9MD-.js +24 -0
  66. package/web/dist/assets/diagram-Z3DM3KII-DmTwvZdS.js +24 -0
  67. package/web/dist/assets/ebnfDiagram-PWID7BFC-DbwmiGVj.js +1 -0
  68. package/web/dist/assets/erDiagram-RLTQ6QDP-Cxiaf87y.js +99 -0
  69. package/web/dist/assets/flowDiagram-HODETNUW-BQiecP1I.js +168 -0
  70. package/web/dist/assets/ganttDiagram-EL5Y4UJY-CSr8RRNw.js +292 -0
  71. package/web/dist/assets/gitGraphDiagram-WWUBYQGX-C4Tdis3g.js +106 -0
  72. package/web/dist/assets/index-CniHcFxT.js +22 -0
  73. package/web/dist/assets/index-DYz2iP_B.css +10 -0
  74. package/web/dist/assets/infoDiagram-27XIBGKW-ij_5A0pY.js +2 -0
  75. package/web/dist/assets/init-Gi6I4Gst.js +1 -0
  76. package/web/dist/assets/ishikawaDiagram-5VMMS53U-C8-tm2LV.js +70 -0
  77. package/web/dist/assets/journeyDiagram-3NMN7TZE-ZJedkNel.js +139 -0
  78. package/web/dist/assets/kanban-definition-UXKFOSKX-BEuoM53n.js +89 -0
  79. package/web/dist/assets/katex-C5jXJg4s.js +257 -0
  80. package/web/dist/assets/layout-zq_9DigZ.js +1 -0
  81. package/web/dist/assets/linear-D-QXJ5Wc.js +1 -0
  82. package/web/dist/assets/markdown-CUPbftfV.js +51 -0
  83. package/web/dist/assets/mermaid.core-CCiG-bOm.js +312 -0
  84. package/web/dist/assets/mindmap-definition-YA3MSWOX-VaAisUIe.js +96 -0
  85. package/web/dist/assets/ordinal-Cboi1Yqb.js +1 -0
  86. package/web/dist/assets/pegDiagram-XKGWAZYB-D5vmRtkn.js +1 -0
  87. package/web/dist/assets/pieDiagram-E7YTZNPT-loD6V8fn.js +39 -0
  88. package/web/dist/assets/quadrantDiagram-AXDQQJYC-B7E4mH1-.js +7 -0
  89. package/web/dist/assets/railroadDiagram-O6MQD6OU-DTALHvDa.js +1 -0
  90. package/web/dist/assets/react-ZkKJMDQQ.js +24 -0
  91. package/web/dist/assets/requirementDiagram-BXWQKSXE-ZjJKuEcR.js +84 -0
  92. package/web/dist/assets/sankeyDiagram-P5KCCOFB-DufTudDP.js +40 -0
  93. package/web/dist/assets/sequenceDiagram-WJ2MYXX4-B-Zk4alI.js +162 -0
  94. package/web/dist/assets/sizeCapture-INFHLROL-B1pjlxXN.js +1 -0
  95. package/web/dist/assets/stateDiagram-D77RDMKH-BeWg6PIR.js +1 -0
  96. package/web/dist/assets/stateDiagram-v2-MP3YSRHH-DagWhIFJ.js +1 -0
  97. package/web/dist/assets/swimlanes-42K2YHIH-4J4jmMmO.js +1 -0
  98. package/web/dist/assets/swimlanesDiagram-VR7AAH4N-Dsi9x540.js +8 -0
  99. package/web/dist/assets/timeline-definition-24CTP7MA-ByqLfxqe.js +120 -0
  100. package/web/dist/assets/vennDiagram-4TSXK5OY-B_EqVW4R.js +34 -0
  101. package/web/dist/assets/wardleyDiagram-VM6X3IG4-Bu7sA6F8.js +78 -0
  102. package/web/dist/assets/xterm-D1D2FVe3.js +38 -0
  103. package/web/dist/assets/xychartDiagram-S5SC5T6Z-w1KPVGKk.js +7 -0
  104. package/web/dist/favicon.svg +8 -0
  105. package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-400-normal.woff2 +0 -0
  106. package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-500-normal.woff2 +0 -0
  107. package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-600-normal.woff2 +0 -0
  108. package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-400-normal.woff2 +0 -0
  109. package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-500-normal.woff2 +0 -0
  110. package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-600-normal.woff2 +0 -0
  111. package/web/dist/fonts/inter/inter-latin-400-normal.woff2 +0 -0
  112. package/web/dist/fonts/inter/inter-latin-500-normal.woff2 +0 -0
  113. package/web/dist/fonts/inter/inter-latin-600-normal.woff2 +0 -0
  114. package/web/dist/fonts/inter/inter-latin-700-normal.woff2 +0 -0
  115. package/web/dist/fonts/inter/inter-latin-800-normal.woff2 +0 -0
  116. package/web/dist/fonts/inter/inter-latin-ext-400-normal.woff2 +0 -0
  117. package/web/dist/fonts/inter/inter-latin-ext-500-normal.woff2 +0 -0
  118. package/web/dist/fonts/inter/inter-latin-ext-600-normal.woff2 +0 -0
  119. package/web/dist/fonts/inter/inter-latin-ext-700-normal.woff2 +0 -0
  120. package/web/dist/fonts/inter/inter-latin-ext-800-normal.woff2 +0 -0
  121. package/web/dist/icon.ico +0 -0
  122. package/web/dist/index.html +17 -0
  123. package/web/public/favicon.svg +8 -0
  124. package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-400-normal.woff2 +0 -0
  125. package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-500-normal.woff2 +0 -0
  126. package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-600-normal.woff2 +0 -0
  127. package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-400-normal.woff2 +0 -0
  128. package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-500-normal.woff2 +0 -0
  129. package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-600-normal.woff2 +0 -0
  130. package/web/public/fonts/inter/inter-latin-400-normal.woff2 +0 -0
  131. package/web/public/fonts/inter/inter-latin-500-normal.woff2 +0 -0
  132. package/web/public/fonts/inter/inter-latin-600-normal.woff2 +0 -0
  133. package/web/public/fonts/inter/inter-latin-700-normal.woff2 +0 -0
  134. package/web/public/fonts/inter/inter-latin-800-normal.woff2 +0 -0
  135. package/web/public/fonts/inter/inter-latin-ext-400-normal.woff2 +0 -0
  136. package/web/public/fonts/inter/inter-latin-ext-500-normal.woff2 +0 -0
  137. package/web/public/fonts/inter/inter-latin-ext-600-normal.woff2 +0 -0
  138. package/web/public/fonts/inter/inter-latin-ext-700-normal.woff2 +0 -0
  139. package/web/public/fonts/inter/inter-latin-ext-800-normal.woff2 +0 -0
  140. package/web/public/icon.ico +0 -0
@@ -0,0 +1,80 @@
1
+ /**
2
+ * node-pty × Node `--watch` compatibility patches (dev-server noise).
3
+ *
4
+ * Node's `--watch` mode (the dev script runs `node --watch --import tsx`)
5
+ * pushes `watch:require` / `watch:import` messages over the IPC channels of
6
+ * spawned worker threads AND forked child processes to track module
7
+ * dependencies. node-pty 1.1.0 does not recognize these and mishandles them in
8
+ * two places:
9
+ *
10
+ * · windowsConoutConnection.js — logs every unknown worker message via
11
+ * `console.warn`, producing hundreds of "Unexpected ConoutWorkerMessage"
12
+ * lines per terminal (the SCM panel's hidden query PTY triggers it on every
13
+ * git-view open).
14
+ * · windowsPtyAgent.js — the kill path's `_getConsoleProcessList()` treats ANY
15
+ * message from the forked console-list agent as its reply; a watch message
16
+ * has no `consoleProcessList` property, so `message.consoleProcessList` is
17
+ * undefined and the `.forEach` on it crashes the server.
18
+ *
19
+ * Both are benign protocol noise: the ConPTY worker only ever sends its READY
20
+ * sentinel and the agent only sends `{ consoleProcessList }` — both of which
21
+ * node-pty handles. These patches make node-pty ignore unrelated IPC traffic.
22
+ *
23
+ * IMPORTANT: this module MUST be imported before node-pty (see terminals.ts) —
24
+ * it rewrites the installed copies on disk so the subsequently-compiled
25
+ * node-pty modules contain the fixed handlers. Best-effort and idempotent
26
+ * (checks before rewriting), mirroring the spawn-helper chmod self-heal; a
27
+ * read-only node_modules or a different node-pty version just skips the patch
28
+ * (the runtime console.warn filter in terminals.ts still guards the flood).
29
+ */
30
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
31
+ import { createRequire } from "node:module";
32
+ import { dirname, join } from "node:path";
33
+ const require = createRequire(import.meta.url);
34
+ /** Absolute path of the installed node-pty package root, or null. */
35
+ function ptyPackageDir() {
36
+ try {
37
+ // require.resolve("node-pty") → <pkg>/lib/index.js → package root is two up.
38
+ return dirname(dirname(require.resolve("node-pty")));
39
+ }
40
+ catch {
41
+ return null;
42
+ }
43
+ }
44
+ /** Idempotently replace one code fragment in a node-pty source file. */
45
+ function patchFile(pkgDir, rel, find, replace) {
46
+ try {
47
+ const file = join(pkgDir, rel);
48
+ if (!existsSync(file))
49
+ return;
50
+ const src = readFileSync(file, "utf8");
51
+ if (src.includes(replace))
52
+ return; // already patched
53
+ if (!src.includes(find))
54
+ return; // unexpected content — leave alone
55
+ writeFileSync(file, src.replace(find, replace), "utf8");
56
+ }
57
+ catch {
58
+ // best-effort — the runtime console.warn filter still guards the flood
59
+ }
60
+ }
61
+ function applyPatches() {
62
+ const pkgDir = ptyPackageDir();
63
+ if (!pkgDir)
64
+ return;
65
+ // 1. ConoutConnection: ignore unknown worker messages instead of warning.
66
+ patchFile(pkgDir, "lib/windowsConoutConnection.js", "console.warn('Unexpected ConoutWorkerMessage', message);", "break; // pi-web-ui: ignore Node --watch worker messages");
67
+ // 2. windowsPtyAgent kill path: only accept a real console-list reply
68
+ // (watch messages arrive first on the fork channel and carry no
69
+ // consoleProcessList — previously resolved undefined and crashed).
70
+ patchFile(pkgDir, "lib/windowsPtyAgent.js", "agent.on('message', function (message) {\n clearTimeout(timeout);\n resolve(message.consoleProcessList);\n });", [
71
+ " agent.on('message', function (message) {",
72
+ " // pi-web-ui: ignore Node --watch messages on the fork channel",
73
+ " if (message && Array.isArray(message.consoleProcessList)) {",
74
+ " clearTimeout(timeout);",
75
+ " resolve(message.consoleProcessList);",
76
+ " }",
77
+ " });",
78
+ ].join("\n"));
79
+ }
80
+ applyPatches();
@@ -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
+ }
@@ -0,0 +1,226 @@
1
+ /**
2
+ * 插件更新辅助(备份/回滚 + 远端 sha 对比)——纯逻辑,供 CLI(bin/pi-web-ui.mjs)
3
+ * 与单测共用。零网络依赖:远端 sha 经注入的 exec 获取(生产 = git ls-remote;
4
+ * 测试 = fake exec 或本地 git 仓库路径,git ls-remote 支持本地仓库,完全离线)。
5
+ *
6
+ * 布局:
7
+ * <dataDir>/plugins/<id>/ 安装本体(含 .pi-source.json + .pi-git-sha)
8
+ * <dataDir>/plugin-backups/<id>-<ts>/ 覆盖安装前的旧版本快照(保留最近 N 份)
9
+ */
10
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, cpSync, writeFileSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { execFile } from "node:child_process";
13
+ const PLUGIN_ID_RE = /^[A-Za-z0-9_-]+$/;
14
+ /** 保留的备份份数(超出删除最旧的)。 */
15
+ export const BACKUP_KEEP = 3;
16
+ /** 默认执行器:execFile 直跑 git(不经 shell),15s 超时。 */
17
+ export const execGit = (cmd, args) => new Promise((resolve) => {
18
+ execFile(cmd, args, { timeout: 15_000, encoding: "utf8" }, (err, stdout, stderr) => {
19
+ if (err)
20
+ resolve({ ok: false, stdout: String(stdout ?? ""), stderr: String(stderr ?? "") });
21
+ else
22
+ resolve({ ok: true, stdout: String(stdout ?? ""), stderr: String(stderr ?? "") });
23
+ });
24
+ });
25
+ /**
26
+ * 覆盖安装前备份旧插件目录 → <dataDir>/plugin-backups/<id>-<ts>/。
27
+ * 目标不存在/备份失败返回 null(调用方可继续——备份是尽力而为的保护)。
28
+ */
29
+ export function ensureBackup(dataDir, id, opts) {
30
+ if (!PLUGIN_ID_RE.test(id))
31
+ return null;
32
+ const target = join(dataDir, "plugins", id);
33
+ if (!existsSync(target))
34
+ return null;
35
+ const ts = stamp();
36
+ const dest = join(dataDir, "plugin-backups", `${id}-${ts}`);
37
+ try {
38
+ mkdirSync(dirnameOf(dest), { recursive: true });
39
+ cpSync(target, dest, {
40
+ recursive: true,
41
+ // 与安装一致:不备份 .git/node_modules(纯运行目录),config.json 等保留。
42
+ filter: (s) => !/(^|[\\/])(\.git|node_modules)([\\/]|$)/.test(s),
43
+ });
44
+ writeFileSync(join(dest, ".pi-backup.json"), JSON.stringify({ id, ts, source: opts?.source }, null, 2) + "\n");
45
+ pruneBackups(dataDir, id);
46
+ return ts;
47
+ }
48
+ catch (err) {
49
+ try {
50
+ rmSync(dest, { recursive: true, force: true });
51
+ }
52
+ catch {
53
+ /* 清理失败忽略 */
54
+ }
55
+ console.warn(`[plugin-updater] 备份 ${id} 失败:`, err instanceof Error ? err.message : err);
56
+ return null;
57
+ }
58
+ }
59
+ /** 该插件的备份目录列表(按时间从新到旧)。 */
60
+ export function listBackups(dataDir, id) {
61
+ if (!PLUGIN_ID_RE.test(id))
62
+ return [];
63
+ const dir = join(dataDir, "plugin-backups");
64
+ let names = [];
65
+ try {
66
+ names = readdirSync(dir);
67
+ }
68
+ catch {
69
+ return [];
70
+ }
71
+ const re = new RegExp(`^${id.replace(/[^A-Za-z0-9_-]/g, "")}-(\\d{8}-\\d{9})$`);
72
+ return names
73
+ .filter((n) => re.test(n) && existsSync(join(dir, n, ".pi-backup.json")))
74
+ .sort()
75
+ .reverse();
76
+ }
77
+ /**
78
+ * 回滚到最近一份备份:删当前 plugins/<id> → 拷贝备份回 → 删备份。
79
+ * 返回最近备份 ts;无备份返回 null。
80
+ */
81
+ export function restoreBackup(dataDir, id) {
82
+ const backups = listBackups(dataDir, id);
83
+ if (backups.length === 0)
84
+ return null;
85
+ const src = join(dataDir, "plugin-backups", backups[0]);
86
+ const target = join(dataDir, "plugins", id);
87
+ try {
88
+ if (existsSync(target))
89
+ rmSync(target, { recursive: true, force: true });
90
+ mkdirSync(join(dataDir, "plugins"), { recursive: true });
91
+ cpSync(src, target, { recursive: true });
92
+ rmSync(src, { recursive: true, force: true });
93
+ return backups[0];
94
+ }
95
+ catch (err) {
96
+ console.warn(`[plugin-updater] 回滚 ${id} 失败:`, err instanceof Error ? err.message : err);
97
+ return null;
98
+ }
99
+ }
100
+ /** 保留最近 BACKUP_KEEP 份,删除更旧的。 */
101
+ export function pruneBackups(dataDir, id, keep = BACKUP_KEEP) {
102
+ const backups = listBackups(dataDir, id);
103
+ for (const b of backups.slice(keep)) {
104
+ try {
105
+ rmSync(join(dataDir, "plugin-backups", b), { recursive: true, force: true });
106
+ }
107
+ catch {
108
+ /* 忽略 */
109
+ }
110
+ }
111
+ }
112
+ /**
113
+ * 解析一个安装源并取远端 HEAD sha(前 12 位)。
114
+ * - 本地 git 仓库路径 / file:// → git ls-remote <path> HEAD(离线)
115
+ * - GitHub owner/repo、URL → git ls-remote https://github.com/o/r.git HEAD
116
+ * - 无法识别 / git 不在 / 网络失败 → null(调用方标记「无法检查」)
117
+ */
118
+ export async function resolveRemoteSha(spec, exec = execGit) {
119
+ const clean = String(spec ?? "").trim();
120
+ if (!clean)
121
+ return null;
122
+ let remote = null;
123
+ if (existsSync(clean)) {
124
+ remote = clean; // 本地 git 仓库路径
125
+ }
126
+ else if (/^file:\/\//i.test(clean)) {
127
+ remote = clean.slice("file://".length);
128
+ }
129
+ else {
130
+ // GitHub 形态(owner/repo、URL、git@)
131
+ let s = clean.replace(/^git@([^:]+):/, "");
132
+ const m = s.match(/^https?:\/\/(?:www\.)?github\.com\/(.+?)(?:\.git)?\/?$/i);
133
+ if (m)
134
+ [, s] = m;
135
+ s = s.split("#")[0]; // 去掉 #分支
136
+ const segs = s.split("/").filter(Boolean);
137
+ if (segs.length < 2)
138
+ return null;
139
+ // 只取 owner/repo(/tree/<ref>/<subpath> 等后缀不影响远端 sha)
140
+ const repo = segs[0] + "/" + segs[1].replace(/\.git$/, "");
141
+ remote = `https://github.com/${repo}.git`;
142
+ }
143
+ if (!remote)
144
+ return null;
145
+ const res = await exec("git", ["ls-remote", remote, "HEAD"]);
146
+ if (!res.ok)
147
+ return null;
148
+ // 行格式: <sha>\tHEAD(可能多行——取第一行)
149
+ const sha = (res.stdout.match(/^([0-9a-f]{40,64})\s+HEAD/m)?.[1]) ?? null;
150
+ return sha ? sha.slice(0, 12) : null;
151
+ }
152
+ /** 扫描全部已装插件,对比本地 sha 与远端 sha,报告更新状态。 */
153
+ export async function checkPluginUpdates(dataDir, exec = execGit) {
154
+ const pluginsDir = join(dataDir, "plugins");
155
+ let names = [];
156
+ try {
157
+ names = readdirSync(pluginsDir).sort();
158
+ }
159
+ catch {
160
+ return [];
161
+ }
162
+ const out = [];
163
+ for (const n of names) {
164
+ if (!PLUGIN_ID_RE.test(n))
165
+ continue;
166
+ const dir = join(pluginsDir, n);
167
+ try {
168
+ const sourceJson = readFileSync(join(dir, ".pi-source.json"), "utf8");
169
+ const { source } = JSON.parse(sourceJson);
170
+ if (!source)
171
+ continue; // 无来源记录(手工拷入)→ skip
172
+ let localSha = null;
173
+ try {
174
+ localSha = readFileSync(join(dir, ".pi-git-sha"), "utf8").trim() || null;
175
+ }
176
+ catch {
177
+ localSha = null; // 无 sha 记录 → 保守认为可更新(不知道装了哪个版本)
178
+ }
179
+ let remoteSha = null;
180
+ let error;
181
+ try {
182
+ remoteSha = await resolveRemoteSha(source, exec);
183
+ }
184
+ catch (err) {
185
+ error = err instanceof Error ? err.message : String(err);
186
+ remoteSha = null;
187
+ }
188
+ if (!remoteSha && !error)
189
+ error = "无法检查(非 git 源或 git 不可用)";
190
+ let name;
191
+ let version;
192
+ try {
193
+ const m = JSON.parse(readFileSync(join(dir, "manifest.json"), "utf8"));
194
+ name = m.name;
195
+ version = m.version;
196
+ }
197
+ catch {
198
+ /* 坏 manifest:仍报告 */
199
+ }
200
+ const updatable = !!remoteSha && (!localSha || localSha !== remoteSha);
201
+ out.push({
202
+ id: n,
203
+ name,
204
+ version,
205
+ source,
206
+ localSha,
207
+ remoteSha,
208
+ updatable,
209
+ error,
210
+ });
211
+ }
212
+ catch {
213
+ continue; // 坏目录跳过
214
+ }
215
+ }
216
+ return out;
217
+ }
218
+ function stamp() {
219
+ const d = new Date();
220
+ const p = (x, n = 2) => String(x).padStart(n, "0");
221
+ return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}${p(d.getMilliseconds(), 3)}`;
222
+ }
223
+ function dirnameOf(p) {
224
+ const i = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\"));
225
+ return i >= 0 ? p.slice(0, i) : null;
226
+ }