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.
@@ -0,0 +1,406 @@
1
+ /**
2
+ * plugin-project — 插件「组装项目」的纯执行层(issue #146)。
3
+ *
4
+ * 插件(`plugins/<id>/`)希望能让宿主把**几个仓库 + 若干配置文件**拼成一个工作区:
5
+ * 建目录、clone、写文件,可选在根目录 `git init`。本模块只做这一件事,并且假定
6
+ * 「能不能在这个目录里动手」已经由上层判过 —— 授权由 `PluginGrantsStore` + 用户
7
+ * 授权弹窗负责,**这里不做授权判断**,只兜住两件必须自己保证的事:
8
+ *
9
+ * 1. **执行**:spawn git(不走 shell)、写盘、逐行回报进度;失败即停,返回
10
+ * `ok:false` + 第一个失败点的原因 + 已经走过的 log —— 绝不吞错、绝不假装成功
11
+ * (插件会把日志原样展示给用户,一个半成品项目配上「成功」比直接报错更坏)。
12
+ * 2. **路径越界防护**:任何相对路径(repo 的 subdir、files 的 key)resolve 之后
13
+ * 都必须仍落在授权根目录 `dir` 之内。授权说的是「这个目录」,不等于它的父目录、
14
+ * 也不等于它内部某个符号链接指向的对面,所以每个目标过三道:
15
+ * ① 直接拒绝绝对路径(含 win32 盘符 / UNC);
16
+ * ② `relative(root, abs)` 不得以 `..` 开头(win32 比较前统一小写 —— 该平台
17
+ * 文件系统大小写不敏感,`E:\A` 与 `e:\a` 是同一个目录);
18
+ * ③ 目标(或它最近一个已存在的祖先)的 **realpath** 仍必须在 root 的 realpath
19
+ * 之内 —— 防 junction / 符号链接把写入引到目录之外。
20
+ * 对用户给的路径不做任何「顺手规整」(trim / 归一):看不懂就直接拒绝。
21
+ *
22
+ * 校验全部在**动磁盘之前**做完(纯函数阶段):一个永远不可能成功的规格不应该先
23
+ * 建出一半目录、clone 半个仓库再报错。
24
+ */
25
+ import { spawn } from "node:child_process";
26
+ import { existsSync, mkdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
27
+ import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
28
+ import { StringDecoder } from "node:string_decoder";
29
+ import { killPidTree } from "./process-utils.js";
30
+ /** 单条 git 命令的默认墙钟上限:大仓库 clone 可能很慢,给足 5 分钟。 */
31
+ const DEFAULT_GIT_TIMEOUT_MS = 5 * 60_000;
32
+ /** 单个文件内容上限(1MB)。 */
33
+ const MAX_FILE_BYTES = 1024 * 1024;
34
+ /** files 条目数上限。 */
35
+ const MAX_FILES = 32;
36
+ /** 单行日志上限:进度条 / 超长 URL 截断后再回给 UI。 */
37
+ const MAX_LINE_CHARS = 400;
38
+ /** 日志总行数上限:clone 输出可能很多,超出后不再追加(结果里标注已截断)。 */
39
+ const MAX_LOG_LINES = 500;
40
+ /** 失败时回传的 stderr 尾部长度。 */
41
+ const MAX_STDERR_TAIL = 1200;
42
+ /** realpath 向上找「已存在祖先」的层数上限(防病态路径原地打转)。 */
43
+ const MAX_ANCESTOR_HOPS = 64;
44
+ /**
45
+ * win32 比较前统一小写:该平台文件系统大小写不敏感,`E:\A` 与 `e:\a` 指同一个目录,
46
+ * 只按字符串比较会把「同一个目录」误判成「在目录之外」或反之。
47
+ */
48
+ function foldCase(p) {
49
+ return process.platform === "win32" ? p.toLowerCase() : p;
50
+ }
51
+ /** 两个绝对路径是否指向同一位置(win32 大小写不敏感)。 */
52
+ export function samePath(a, b) {
53
+ return foldCase(resolve(a)) === foldCase(resolve(b));
54
+ }
55
+ /**
56
+ * abs 是否落在 root 之内(含 root 自身)。用 `relative()` 判定:
57
+ * `""` = root 本身;以 `..` 开头 = 越界;`relative()` 回一个绝对路径 = 跨盘符。
58
+ */
59
+ export function isInsideRoot(root, abs) {
60
+ const rel = relative(foldCase(root), foldCase(abs));
61
+ if (rel === "")
62
+ return true;
63
+ if (isAbsolute(rel))
64
+ return false;
65
+ return rel !== ".." && !rel.startsWith(`..${sep}`);
66
+ }
67
+ /**
68
+ * 把调用方给的**相对路径**解析到 root 内:返回绝对路径,越界/非法返回 null。
69
+ * 拒绝:非字符串、空串、含 NUL、绝对路径、解析后逃出 root。
70
+ */
71
+ export function resolveInsideRoot(root, raw) {
72
+ if (typeof raw !== "string")
73
+ return null;
74
+ if (raw === "" || raw.includes("\0"))
75
+ return null;
76
+ if (isAbsolute(raw))
77
+ return null;
78
+ const abs = resolve(root, raw);
79
+ return isInsideRoot(root, abs) ? abs : null;
80
+ }
81
+ /** realpath,两条实现都试(`.native` 在少数环境缺失)。 */
82
+ function realpathOf(p) {
83
+ try {
84
+ return realpathSync.native(p);
85
+ }
86
+ catch {
87
+ return realpathSync(p);
88
+ }
89
+ }
90
+ /**
91
+ * 目标自身(存在时)或它最近一个**已存在的祖先**的真实路径;一路到盘根都解析不出来 → null。
92
+ * 用途是复核符号链接:`<root>/link/file.txt` 里 link 若是指向别处的 junction,
93
+ * 单看字符串它「在 root 里」,realpath 之后就露馅了。
94
+ */
95
+ function realPathOfNearest(abs) {
96
+ let probe = abs;
97
+ for (let i = 0; i < MAX_ANCESTOR_HOPS; i++) {
98
+ try {
99
+ return realpathOf(probe);
100
+ }
101
+ catch {
102
+ const parent = dirname(probe);
103
+ if (parent === probe)
104
+ return null; // 到盘根了还不存在
105
+ probe = parent;
106
+ }
107
+ }
108
+ return null;
109
+ }
110
+ /** 相对路径统一成 `/` 分隔(wire / 日志 / 前端都用正斜杠,Windows 的 path 会回 `\`)。 */
111
+ function toSlash(p) {
112
+ return p.split(sep).join("/");
113
+ }
114
+ function errMessage(err) {
115
+ return err instanceof Error ? err.message : String(err);
116
+ }
117
+ /**
118
+ * git 运行环境:服务端没有 TTY,**任何交互式认证都会永久挂死**(工具看门狗只能杀
119
+ * 进程,用户体验是「卡 20 分钟然后失败」)。所以一律关掉提问通道:
120
+ * GIT_TERMINAL_PROMPT 管 git 自己的用户名/密码提示,GCM_INTERACTIVE 管 Windows
121
+ * 凭据管理器(它不看前者),GIT_SSH_COMMAND 的 BatchMode 管 ssh 的密码/指纹确认。
122
+ * 用户自己设的 GIT_SSH_COMMAND 优先(保留自定义 key / 代理配置)。
123
+ */
124
+ function gitEnv() {
125
+ return {
126
+ ...process.env,
127
+ GIT_TERMINAL_PROMPT: "0",
128
+ GCM_INTERACTIVE: "never",
129
+ GIT_PAGER: "cat",
130
+ GIT_SSH_COMMAND: process.env.GIT_SSH_COMMAND ?? "ssh -o BatchMode=yes",
131
+ };
132
+ }
133
+ /** 把数据流按行喂给 onLine(跨 chunk 的半行缓存在闭包里,UTF-8 用 StringDecoder 拼接)。 */
134
+ function makeLineReader(onLine) {
135
+ const decoder = new StringDecoder("utf8");
136
+ let rest = "";
137
+ const emit = (raw) => {
138
+ // git 的 `\r` 进度条("Receiving objects: 45%")压成一行,避免刷屏。
139
+ const line = raw.replace(/\r/g, " ").trimEnd();
140
+ if (line !== "")
141
+ onLine(line);
142
+ };
143
+ return {
144
+ push(chunk) {
145
+ rest += decoder.write(chunk);
146
+ const parts = rest.split("\n");
147
+ rest = parts.pop() ?? "";
148
+ for (const p of parts)
149
+ emit(p);
150
+ },
151
+ flush() {
152
+ rest += decoder.end();
153
+ if (rest !== "")
154
+ emit(rest);
155
+ rest = "";
156
+ },
157
+ };
158
+ }
159
+ /** 跑一条 git 命令:stdout/stderr 逐行进日志,超时连坐整棵进程树。 */
160
+ async function runGit(bin, args, opts) {
161
+ return await new Promise((settle) => {
162
+ let child;
163
+ try {
164
+ child = spawn(bin, args, {
165
+ cwd: opts.cwd,
166
+ env: opts.env,
167
+ // spawn 用 argv 数组,不过 shell(URL/ref 里的 `; rm -rf` 无效)。
168
+ // POSIX 下让它自成进程组:超时要杀的是整棵树(git 会拉起 ssh /
169
+ // credential helper 子进程),killPidTree 用 -pid 干活需要这一点。
170
+ detached: process.platform !== "win32",
171
+ stdio: ["ignore", "pipe", "pipe"],
172
+ windowsHide: true,
173
+ });
174
+ }
175
+ catch (err) {
176
+ settle({
177
+ code: -1,
178
+ spawnError: `无法启动 git(${bin}):${errMessage(err)}`,
179
+ timedOut: false,
180
+ stderrTail: "",
181
+ });
182
+ return;
183
+ }
184
+ const stderrLines = [];
185
+ const stdout = makeLineReader((line) => opts.onLine(`git: ${line}`));
186
+ // stderr 同时进日志(git 的进展、远端提示全在 stderr)和失败原因尾部。
187
+ const stderr = makeLineReader((line) => {
188
+ stderrLines.push(line);
189
+ opts.onLine(`git: ${line}`);
190
+ });
191
+ child.stdout?.on("data", (chunk) => stdout.push(chunk));
192
+ child.stderr?.on("data", (chunk) => stderr.push(chunk));
193
+ let timedOut = false;
194
+ const timer = setTimeout(() => {
195
+ timedOut = true;
196
+ if (typeof child.pid === "number" && child.pid > 0)
197
+ killPidTree(child.pid);
198
+ else
199
+ child.kill("SIGKILL");
200
+ }, opts.timeoutMs);
201
+ let spawnError;
202
+ child.on("error", (err) => {
203
+ spawnError = `无法启动 git(${bin}):${errMessage(err)}`;
204
+ });
205
+ child.on("close", (code) => {
206
+ clearTimeout(timer);
207
+ stdout.flush();
208
+ stderr.flush();
209
+ let tail = stderrLines.join("\n");
210
+ if (tail.length > MAX_STDERR_TAIL)
211
+ tail = `…${tail.slice(-MAX_STDERR_TAIL)}`;
212
+ settle({ code: code ?? -1, timedOut, stderrTail: tail, ...(spawnError ? { spawnError } : {}) });
213
+ });
214
+ });
215
+ }
216
+ /**
217
+ * 组装项目:mkdir 子目录 → clone 仓库 → 写文件 → 可选 git init。
218
+ * 失败即停(返回 ok:false + 原因 + 已完成的 log),不吞错、不假装成功。
219
+ */
220
+ export async function createProject(spec, deps = {}) {
221
+ const log = [];
222
+ let truncated = false;
223
+ /** 每步一行:先进 log(结果里原样带回),再回调上层(转发到浏览器 / 终端)。 */
224
+ const emit = (line) => {
225
+ const text = line.length > MAX_LINE_CHARS ? `${line.slice(0, MAX_LINE_CHARS)}…` : line;
226
+ if (log.length >= MAX_LOG_LINES) {
227
+ truncated = true;
228
+ return;
229
+ }
230
+ log.push(text);
231
+ try {
232
+ deps.onProgress?.(text);
233
+ }
234
+ catch {
235
+ // 进度回调是 UI 层的事:它抛错不能中断组装(更不能把项目搞成半成品)。
236
+ }
237
+ };
238
+ let root = typeof spec?.dir === "string" ? spec.dir : "";
239
+ const finish = (ok, error) => {
240
+ if (truncated) {
241
+ truncated = false;
242
+ log.push(`…(输出超过 ${MAX_LOG_LINES} 行,已截断)`);
243
+ }
244
+ return error === undefined ? { ok, log, dir: root } : { ok, error, log, dir: root };
245
+ };
246
+ const fail = (error) => {
247
+ emit(`失败:${error}`);
248
+ return finish(false, error);
249
+ };
250
+ // ——— ① 校验根目录:必须是「已存在的绝对路径」。先判 isAbsolute 再 resolve:
251
+ // resolve 会拿进程 cwd 把相对路径补成绝对,那会掩盖调用方的错误。
252
+ const rawDir = spec?.dir;
253
+ if (typeof rawDir !== "string" || rawDir === "")
254
+ return fail("缺少项目根目录 dir(必须是非空绝对路径)");
255
+ if (!isAbsolute(rawDir))
256
+ return fail(`项目根目录必须是绝对路径:${rawDir}`);
257
+ root = resolve(rawDir);
258
+ try {
259
+ if (!statSync(root).isDirectory())
260
+ return fail(`项目根目录不是文件夹:${root}`);
261
+ }
262
+ catch {
263
+ return fail(`项目根目录不存在:${root}(本模块不创建新的根目录)`);
264
+ }
265
+ // 真实路径:后面每个写盘目标都要拿它复核(防符号链接/junction 逃逸)。
266
+ const rootReal = realPathOfNearest(root) ?? root;
267
+ // ——— ② 校验 repos(纯计算,不碰磁盘)———
268
+ const rawRepos = spec?.repos;
269
+ if (rawRepos !== undefined && !Array.isArray(rawRepos))
270
+ return fail("repos 必须是数组");
271
+ const plannedRepos = [];
272
+ const repoItems = rawRepos ?? [];
273
+ for (let i = 0; i < repoItems.length; i++) {
274
+ const item = repoItems[i];
275
+ const url = typeof item?.url === "string" ? item.url.trim() : "";
276
+ if (url === "")
277
+ return fail(`repos[${i}].url 必须是非空字符串`);
278
+ // spawn 不过 shell,但 git 自己会把以 "-" 开头的实参当**选项**解析:
279
+ // `--upload-pack=<cmd>` / `-c core.sshCommand=…` 这类能让远端/本地执行任意
280
+ // 命令,必须挡在解析之前(argv 数组只挡得住注入 shell,挡不住选项注入)。
281
+ if (url.startsWith("-"))
282
+ return fail(`repos[${i}].url 不能以 "-" 开头(防 git 选项注入):${url}`);
283
+ const rawRef = item?.ref;
284
+ let ref;
285
+ if (rawRef !== undefined && rawRef !== null && rawRef !== "") {
286
+ if (typeof rawRef !== "string")
287
+ return fail(`repos[${i}].ref 必须是字符串`);
288
+ ref = rawRef.trim();
289
+ if (ref === "" || ref.startsWith("-"))
290
+ return fail(`repos[${i}].ref 非法:${String(rawRef)}`);
291
+ }
292
+ const rawSubdir = item?.subdir;
293
+ let dest = root;
294
+ let destRel = ".";
295
+ if (rawSubdir !== undefined && rawSubdir !== null && rawSubdir !== "") {
296
+ if (typeof rawSubdir !== "string")
297
+ return fail(`repos[${i}].subdir 必须是字符串`);
298
+ const abs = resolveInsideRoot(root, rawSubdir);
299
+ if (abs === null)
300
+ return fail(`repos[${i}].subdir 路径越界(必须落在 dir 之内):${rawSubdir}`);
301
+ dest = abs;
302
+ destRel = toSlash(relative(root, abs));
303
+ }
304
+ plannedRepos.push({ url, dest, destRel, replace: item?.replace === true, ...(ref ? { ref } : {}) });
305
+ }
306
+ // ③ 真实路径复核 + replace 的边界:任何一个目标解析后跑出 root 就整单拒绝。
307
+ for (let i = 0; i < plannedRepos.length; i++) {
308
+ const r = plannedRepos[i];
309
+ // 缺省 subdir = 直接 clone 进根目录本身:根是调用方授权的那一层,
310
+ // replace 删它等于删用户自己的项目目录(不是「清空一个子目录」),拒绝。
311
+ if (r.replace && samePath(r.dest, root)) {
312
+ return fail(`repos[${i}]: replace 不能用于项目根目录(拒绝删除 dir 本身)`);
313
+ }
314
+ const real = realPathOfNearest(r.dest);
315
+ if (real === null)
316
+ return fail(`repos[${i}].subdir 无法解析真实路径:${r.destRel}`);
317
+ if (!isInsideRoot(rootReal, real)) {
318
+ return fail(`repos[${i}].subdir 路径越界(符号链接指向 dir 之外):${r.destRel}`);
319
+ }
320
+ }
321
+ // ——— ④ 校验 files(纯计算)———
322
+ const rawFiles = spec?.files;
323
+ if (rawFiles !== undefined && (rawFiles === null || typeof rawFiles !== "object" || Array.isArray(rawFiles))) {
324
+ return fail("files 必须是「相对路径 → 文本内容」的对象");
325
+ }
326
+ const fileEntries = Object.entries(rawFiles ?? {});
327
+ if (fileEntries.length > MAX_FILES)
328
+ return fail(`files 条目数超限:${fileEntries.length} > ${MAX_FILES}`);
329
+ const plannedFiles = [];
330
+ for (const [rel, content] of fileEntries) {
331
+ const abs = resolveInsideRoot(root, rel);
332
+ if (abs === null)
333
+ return fail(`files 路径越界或非法(必须是 dir 内的相对路径):${rel}`);
334
+ if (typeof content !== "string")
335
+ return fail(`files["${rel}"] 必须是字符串内容`);
336
+ const bytes = Buffer.byteLength(content, "utf8");
337
+ if (bytes > MAX_FILE_BYTES)
338
+ return fail(`files["${rel}"] 超过单文件上限 1MB(${bytes} 字节)`);
339
+ // 目标自身(存在时)或其最近祖先的 realpath 也要在 root 里:
340
+ // 目标可能是父目录里的 junction,也可能是「指向 /etc/passwd 的文件链接」。
341
+ const real = realPathOfNearest(abs);
342
+ if (real === null || !isInsideRoot(rootReal, real)) {
343
+ return fail(`files 路径越界(符号链接指向 dir 之外):${rel}`);
344
+ }
345
+ plannedFiles.push({ rel: toSlash(relative(root, abs)), abs, content });
346
+ }
347
+ // ——— ⑤ 执行:校验全过了才动磁盘 ———
348
+ const gitBin = deps.gitBin && deps.gitBin.trim() !== "" ? deps.gitBin : "git";
349
+ const timeoutMs = deps.gitTimeoutMs && deps.gitTimeoutMs > 0 ? deps.gitTimeoutMs : DEFAULT_GIT_TIMEOUT_MS;
350
+ const env = gitEnv();
351
+ const gitFailReason = (what, res) => {
352
+ if (res.spawnError)
353
+ return res.spawnError;
354
+ if (res.timedOut)
355
+ return `${what} 超时(> ${Math.round(timeoutMs / 1000)}s)`;
356
+ if (res.code !== 0)
357
+ return `${what} 失败(退出码 ${res.code})`;
358
+ return undefined;
359
+ };
360
+ try {
361
+ for (const r of plannedRepos) {
362
+ const atRoot = samePath(r.dest, root);
363
+ // 根目录已存在是常态(它就是那个已存在的授权目录),既不 rm 也不 mkdir ——
364
+ // 交给 git 判断它是否为空目录(非空时 git 自己会报 "not an empty directory")。
365
+ if (!atRoot && existsSync(r.dest)) {
366
+ // 目标已存在:只有显式 replace 才动它 —— 绝不静默覆盖用户数据。
367
+ if (!r.replace)
368
+ return fail(`目标目录已存在:${r.destRel}(要覆盖请显式 replace:true)`);
369
+ emit(`rm -rf ${r.destRel}`);
370
+ rmSync(r.dest, { recursive: true, force: true });
371
+ }
372
+ if (!atRoot) {
373
+ emit(`mkdir ${r.destRel}`);
374
+ mkdirSync(r.dest, { recursive: true });
375
+ }
376
+ emit(`clone ${r.url} → ${r.destRel}`);
377
+ const args = ["clone", "--depth", "1"];
378
+ if (r.ref)
379
+ args.push("--branch", r.ref);
380
+ // 目标用绝对路径 + cwd=root:相对路径交给 git 会被它按自己的规则再解释一遍。
381
+ args.push(r.url, r.dest);
382
+ const res = await runGit(gitBin, args, { cwd: root, timeoutMs, env, onLine: emit });
383
+ const why = gitFailReason("git clone", res);
384
+ if (why)
385
+ return fail(`${why}${res.stderrTail === "" ? "" : `:${res.stderrTail}`}`);
386
+ }
387
+ for (const f of plannedFiles) {
388
+ emit(`write ${f.rel}`);
389
+ mkdirSync(dirname(f.abs), { recursive: true });
390
+ writeFileSync(f.abs, f.content, "utf8");
391
+ }
392
+ if (spec?.gitInit === true) {
393
+ emit("git init");
394
+ const res = await runGit(gitBin, ["init"], { cwd: root, timeoutMs, env, onLine: emit });
395
+ const why = gitFailReason("git init", res);
396
+ if (why)
397
+ return fail(`${why}${res.stderrTail === "" ? "" : `:${res.stderrTail}`}`);
398
+ }
399
+ return finish(true);
400
+ }
401
+ catch (err) {
402
+ // 兜底:fs 层意外错误(权限、盘满、目录被并发删掉)也走 ok:false,
403
+ // 不让插件看到一个抛出来的异常(它只认 ok/error/log)。
404
+ return fail(`组装失败:${errMessage(err)}`);
405
+ }
406
+ }