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,281 @@
1
+ /**
2
+ * plugin-grants —— 插件目录授权表(issue #146)。
3
+ *
4
+ * 插件服务端跑的是全权 Node 代码,宿主只能在「自己提供的 API」上做闸门
5
+ * (见 server/plugins.ts 的 registerAgentTool / route / host.fs)。当插件要碰
6
+ * **工作区之外**的目录时,宿主弹确认框问用户;用户同意后可以选「记住」——
7
+ * 记住的东西就落在这个文件里:`<dataDir>/plugin-grants.json`
8
+ * (`{ grants: { <pluginId>: [绝对目录…] } }`),设置面板可随时撤销。
9
+ *
10
+ * 为什么单独一个文件而不是塞进 client-state.json:授权是**安全语义**的持久化
11
+ * 状态,与 UI 偏好/最近项目是两回事 —— 用户清 UI 状态、迁移设置预设时不该
12
+ * 连带丢(或带上)一份授权;文件独立后也能被运维一眼看懂、手改、审计。
13
+ *
14
+ * 三条设计取舍(都有意为之,改动前请先读):
15
+ *
16
+ * 1. **父目录授权覆盖子目录**:`has()` 命中目录本身或其任一祖先。
17
+ * 理由:目录授权天然是「子树授权」—— 用户批准 `/proj` 时的心理模型就是
18
+ * 「这个项目目录随你用」,若还要求逐个 `/proj/src`、`/proj/docs` 再弹十次
19
+ * 确认,用户只会一路盲点「同意」,闸门形同虚设(安全上反而更糟)。反之
20
+ * **不成立**:只授权了 `/proj/a` 时访问 `/proj` 必须再问 —— 祖先目录里
21
+ * 还躺着用户没打算交出去的东西。判定按路径分段边界做(`/proj` 不覆盖
22
+ * `/project`),不做字符串前缀匹配。
23
+ *
24
+ * 2. **坏文件不覆盖**:读失败 / JSON 坏 / 形状不对一律当作空表(绝不抛给调用方,
25
+ * 也绝不在读路径上回写)。因为「读不出来」与「确实是空的」在故障态下无法
26
+ * 区分,若读失败就写一份空表回去,等于用一次磁盘抖动**静默清空用户全部授权**
27
+ * (或更糟:把手工修了一半的文件冲掉)。只有真正发生变更的写操作
28
+ * (grant / revoke 命中)才重写文件,此时以内存里的净化为准。
29
+ *
30
+ * 3. **文件 I/O 全部 best-effort**:磁盘错误不能让 server 崩(与
31
+ * server/client-state.ts 同风格)。写失败 = 这次授权没落盘,下次再问用户;
32
+ * 内存态仍然生效,本次会话可用。
33
+ *
34
+ * 路径归一:一律 `resolve()` 成绝对路径再存;相对路径直接拒绝(相对谁是个隐藏
35
+ * 上下文,落盘后换个 cwd 就读出另一个目录,等于埋雷)。win32 上比较时忽略大小写
36
+ * 与尾部分隔符(Windows 文件系统不区分大小写),但**存储保持用户写入时的形式**
37
+ * (不 lowerCase),否则设置面板里 `C:\Users\Foo` 会显示成 `c:\users\foo`。
38
+ */
39
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
40
+ import { dirname, isAbsolute, resolve, sep } from "node:path";
41
+ /** 合法插件 id(与 server/plugins.ts 的 ID_RE 一致,防路径穿越)。 */
42
+ const ID_RE = /^[A-Za-z0-9_-]+$/;
43
+ /** 去掉尾部分隔符(保留根:「C:\」/「/」不能退化)。 */
44
+ function stripTrailingSep(p) {
45
+ let out = p;
46
+ for (;;) {
47
+ if (out.length <= 1)
48
+ return out;
49
+ const last = out[out.length - 1];
50
+ if (last !== "/" && last !== "\\")
51
+ return out;
52
+ const trimmed = out.slice(0, -1);
53
+ // 「C:\」→「C:」在 win32 上语义完全不同(后者是「C 盘当前目录」),不能削。
54
+ if (/^[A-Za-z]:$/.test(trimmed))
55
+ return out;
56
+ out = trimmed;
57
+ }
58
+ }
59
+ /** 比较用键:win32 折大小写(文件系统不区分大小写);尾部分隔符统一去掉
60
+ * (`C:\a\` 与 `C:\a` 是同一目录,手改文件很容易多写一个斜杠)。 */
61
+ function pathKey(p) {
62
+ const s = stripTrailingSep(p);
63
+ return process.platform === "win32" ? s.toLowerCase() : s;
64
+ }
65
+ /** 路径归一:绝对化 + 去尾分隔符;非法(空 / 非字符串 / 含 NUL / 相对 / 盘符怪)→ null。
66
+ * 存储的也是这个形式(用户写入形式的绝对化版本)。 */
67
+ export function normalizeGrantPath(p) {
68
+ if (typeof p !== "string")
69
+ return null;
70
+ const raw = p.trim();
71
+ if (!raw || raw.includes("\0"))
72
+ return null;
73
+ // 相对路径一律拒绝:授权必须是「明确的绝对目录」,不做 cwd 相对解析。
74
+ // 顺带干掉 win32 上的怪盘符形式("C:relative"、"1:\x" 都是 isAbsolute=false)。
75
+ if (!isAbsolute(raw))
76
+ return null;
77
+ let abs;
78
+ try {
79
+ abs = resolve(raw);
80
+ }
81
+ catch {
82
+ return null;
83
+ }
84
+ if (!isAbsolute(abs))
85
+ return null;
86
+ return stripTrailingSep(abs);
87
+ }
88
+ /** target 是否落在 ancestor 自身或它的子树上(按分段边界,不做裸前缀匹配:
89
+ * `/proj` 不能覆盖 `/project`)。两边都须是 pathKey() 归一后的形式。 */
90
+ function isSameOrInside(targetKey, ancestorKey) {
91
+ if (targetKey === ancestorKey)
92
+ return true;
93
+ const prefix = ancestorKey.endsWith(sep) ? ancestorKey : ancestorKey + sep;
94
+ return targetKey.startsWith(prefix);
95
+ }
96
+ /** 原子写:临时文件 + rename(同 server/plugin-catalog.ts 的写法;本文件不 import
97
+ * 它,避免为一个 3 行函数制造模块耦合)。 */
98
+ function atomicWriteJson(filePath, data) {
99
+ mkdirSync(dirname(filePath), { recursive: true });
100
+ const tmp = `${filePath}.tmp-${process.pid}`;
101
+ writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n");
102
+ renameSync(tmp, filePath);
103
+ }
104
+ /** 读文件并净化为内存表:任何异常(不存在 / 权限 / JSON 坏 / 形状不对)→ 空表。
105
+ * 条目逐个过 normalizeGrantPath(顺手兼容手工写入的相对路径、重复项、坏 id)。 */
106
+ function readGrantsFile(filePath) {
107
+ let raw;
108
+ try {
109
+ raw = JSON.parse(readFileSync(filePath, "utf8"));
110
+ }
111
+ catch {
112
+ return {};
113
+ }
114
+ const grants = raw && typeof raw === "object" && !Array.isArray(raw) ? raw.grants : undefined;
115
+ if (!grants || typeof grants !== "object" || Array.isArray(grants))
116
+ return {};
117
+ const out = {};
118
+ for (const [id, paths] of Object.entries(grants)) {
119
+ if (!ID_RE.test(id) || !Array.isArray(paths))
120
+ continue;
121
+ const seen = new Set();
122
+ const list = [];
123
+ for (const p of paths) {
124
+ if (typeof p !== "string")
125
+ continue;
126
+ const norm = normalizeGrantPath(p);
127
+ if (!norm)
128
+ continue;
129
+ const key = pathKey(norm);
130
+ if (seen.has(key))
131
+ continue;
132
+ seen.add(key);
133
+ list.push(norm);
134
+ }
135
+ if (list.length > 0)
136
+ out[id] = list;
137
+ }
138
+ return out;
139
+ }
140
+ /**
141
+ * 插件目录授权表。全局共享(不是 per-client):授权是「这台机器上的这套插件
142
+ * 配置允许它访问哪些目录」,任何浏览器/标签页看到的都该是同一份。
143
+ */
144
+ export class PluginGrantsStore {
145
+ dataDir;
146
+ /** 内存缓存 = 上次读盘结果(含净化)。null = 尚未读 / 已被 invalidate。 */
147
+ cache = null;
148
+ /** @param dataDir 数据目录;授权文件固定为 `<dataDir>/plugin-grants.json`。 */
149
+ constructor(dataDir) {
150
+ this.dataDir = dataDir;
151
+ }
152
+ /** 授权文件路径(测试 / 排障 / 审计用)。 */
153
+ get filePath() {
154
+ return resolve(this.dataDir, "plugin-grants.json");
155
+ }
156
+ load() {
157
+ if (!this.cache)
158
+ this.cache = readGrantsFile(this.filePath);
159
+ return this.cache;
160
+ }
161
+ /** 落盘(best-effort):失败只丢持久化,不影响本次会话的内存态。 */
162
+ save() {
163
+ try {
164
+ atomicWriteJson(this.filePath, { grants: this.load() });
165
+ }
166
+ catch {
167
+ // best effort —— 磁盘故障不能弄崩 server
168
+ }
169
+ }
170
+ /** 全部授权,按插件聚合(设置面板展示用;路径保持写入时的形式)。
171
+ * 按 pluginId 排序,保证界面顺序稳定(与本文件里的插入顺序无关)。 */
172
+ list() {
173
+ const all = this.load();
174
+ return Object.keys(all)
175
+ .sort()
176
+ .map((pluginId) => ({ pluginId, paths: [...all[pluginId]] }));
177
+ }
178
+ /** 单个插件的授权目录(副本;不存在或 id 非法 → 空数组)。 */
179
+ get(pluginId) {
180
+ if (!ID_RE.test(pluginId))
181
+ return [];
182
+ return [...(this.load()[pluginId] ?? [])];
183
+ }
184
+ /**
185
+ * 是否已授权:命中目录本身,**或它的任一祖先目录已被授权**(见文件头取舍 1)。
186
+ * 纯字符串判定,不碰磁盘(目录可能还不存在,也可能已被删 —— 授权表是
187
+ * 「用户批准过」,不是「磁盘现状」)。
188
+ */
189
+ has(pluginId, dir) {
190
+ if (!ID_RE.test(pluginId))
191
+ return false;
192
+ const target = normalizeGrantPath(dir);
193
+ if (!target)
194
+ return false;
195
+ const list = this.load()[pluginId];
196
+ if (!list || list.length === 0)
197
+ return false;
198
+ const targetKey = pathKey(target);
199
+ for (const granted of list) {
200
+ if (isSameOrInside(targetKey, pathKey(granted)))
201
+ return true;
202
+ }
203
+ return false;
204
+ }
205
+ /**
206
+ * 记一条授权(幂等:同一目录不重复)。返回是否**新增**。
207
+ *
208
+ * 幂等按归一后的同一目录判(win32 大小写 / 尾部分隔符等价形式算同一个)。
209
+ * 注意:父目录已授权时再显式 grant 子目录仍然会记下(返回 true)——
210
+ * 授权表是「用户知情过什么」的记录,显式批准过的子目录值得留痕,之后撤销
211
+ * 父目录时它不会莫名其妙一起消失(has 侧照样被父目录短路,不产生额外询问)。
212
+ */
213
+ grant(pluginId, dir) {
214
+ if (!ID_RE.test(pluginId))
215
+ return false;
216
+ const norm = normalizeGrantPath(dir);
217
+ if (!norm)
218
+ return false;
219
+ const all = this.load();
220
+ const list = all[pluginId] ?? [];
221
+ const key = pathKey(norm);
222
+ if (list.some((p) => pathKey(p) === key))
223
+ return false;
224
+ all[pluginId] = [...list, norm];
225
+ this.save();
226
+ return true;
227
+ }
228
+ /**
229
+ * 撤销授权,三种粒度,返回删除的条数:
230
+ * - `revoke()` 清空整张表
231
+ * - `revoke(pluginId)` 清掉该插件全部
232
+ * - `revoke(pluginId, dir)` 只清该目录(按归一后的同一目录精确匹配,
233
+ * 不连带删除它下面的授权条目)
234
+ * 只给 dir 不给 pluginId 是非法用法(不知道删谁的)→ 0,不动表。
235
+ * 无变化时**不写盘**:坏文件/空表不该被一次空撤销顺手覆盖(见取舍 2)。
236
+ */
237
+ revoke(pluginId, dir) {
238
+ const all = this.load();
239
+ if (pluginId === undefined) {
240
+ if (dir !== undefined)
241
+ return 0; // 非法用法:只给目录无法定位插件
242
+ let total = 0;
243
+ for (const list of Object.values(all))
244
+ total += list.length;
245
+ if (total === 0)
246
+ return 0;
247
+ for (const id of Object.keys(all))
248
+ delete all[id];
249
+ this.save();
250
+ return total;
251
+ }
252
+ if (!ID_RE.test(pluginId))
253
+ return 0;
254
+ const list = all[pluginId];
255
+ if (!list || list.length === 0)
256
+ return 0;
257
+ if (dir === undefined) {
258
+ delete all[pluginId];
259
+ this.save();
260
+ return list.length;
261
+ }
262
+ const norm = normalizeGrantPath(dir);
263
+ if (!norm)
264
+ return 0;
265
+ const key = pathKey(norm);
266
+ const next = list.filter((p) => pathKey(p) !== key);
267
+ const removed = list.length - next.length;
268
+ if (removed === 0)
269
+ return 0;
270
+ if (next.length > 0)
271
+ all[pluginId] = next;
272
+ else
273
+ delete all[pluginId];
274
+ this.save();
275
+ return removed;
276
+ }
277
+ /** 磁盘文件变化(多进程 / 用户手改)后重读:丢缓存,下次访问重新读盘。 */
278
+ reload() {
279
+ this.cache = null;
280
+ }
281
+ }
@@ -0,0 +1,238 @@
1
+ /**
2
+ * plugin-installer — 插件的后台安装作业(安装 / 更新 / 卸载)。
3
+ *
4
+ * 设置面板里的插件操作原本开一个**可见终端 tab** 跑 CLI:过程看得见,但每次操作
5
+ * 都切主视图 + 关掉设置弹窗 —— 连装几个插件就是「装一个、重开设置、再导航回市场」
6
+ * (issue #152)。这里把同一件事搬到服务端后台:
7
+ *
8
+ * - 真正执行者仍是 CLI(`pi-web-ui install|uninstall`,bin/pi-web-ui.mjs)——
9
+ * 单一实现,界面与终端行为永不漂移(`--build` 之类新选项自动同步);
10
+ * - 输出按行回传(`plugin_job` 的 log 段),结束时回 `done`(成功与否 + 输出尾部),
11
+ * 前端就地显示,弹窗不关;
12
+ * - 完成后由调用方(index.ts)重扫插件 + 重推市场列表;
13
+ * - **同一时刻只跑一个作业**:安装要动 `<dataDir>/plugins/<id>`,两个作业写同一
14
+ * 目录必出半装状态,所以第二个请求直接被拒(提示等它结束);
15
+ * - 看门狗:超时杀掉整棵进程树,绝不留一个卡死的 install 占着锁。
16
+ *
17
+ * 它不决定「谁能装」:managed 实例与 tabs 闸门在 index.ts 的 dispatch 上做(见
18
+ * managed.ts 的「服务端拒绝,客户端只是隐藏」);这里只兜一层,防止别的调用路径绕过。
19
+ */
20
+ import { spawn } from "node:child_process";
21
+ import { existsSync } from "node:fs";
22
+ import { join } from "node:path";
23
+ import { pick } from "./i18n.js";
24
+ import { isValidSource } from "./plugin-catalog.js";
25
+ import { killPidTree } from "./process-utils.js";
26
+ /** 插件 id 字符集(与 server/plugins.ts、plugin-catalog.ts 一致,防路径穿越)。 */
27
+ const ID_RE = /^[A-Za-z0-9_-]+$/;
28
+ /** 单个作业的墙钟上限(clone + 依赖安装 + 编译都可能很慢,给足 15 分钟)。 */
29
+ const DEFAULT_TIMEOUT_MS = 15 * 60_000;
30
+ /** 回传给前端的输出尾部上限(失败时展开排障用,别把整个 build 日志塞进消息)。 */
31
+ const MAX_OUTPUT_CHARS = 8000;
32
+ /** 单行上限:某些工具会吐超长单行(进度条),截断后再回传。 */
33
+ const MAX_LINE_CHARS = 2000;
34
+ /** 纯函数:把作业规格翻成 CLI argv(含安全校验),便于单测。
35
+ * 返回 `{ args }` 或 `{ error }`(文案按 lang 本地化)。 */
36
+ export function buildPluginJobArgs(spec, dataDir, lang) {
37
+ const l = lang?.() ?? "en";
38
+ const id = String(spec?.id ?? "").trim();
39
+ if (!ID_RE.test(id))
40
+ return {
41
+ error: pick(l, `非法插件 id "${id}"(仅限字母数字-_)`, `Invalid plugin id "${id}" (letters/digits/-/_ only)`, "plugininstaller.id.invalid", { id }),
42
+ };
43
+ if (spec.action === "uninstall")
44
+ return { args: ["uninstall", id, "--data-dir", dataDir] };
45
+ const source = String(spec?.source ?? "").trim();
46
+ if (!isValidSource(source))
47
+ return {
48
+ error: pick(l, "安装源需为 owner/repo 或 owner/repo/子目录(本地路径请用命令行安装)", "Install source must be owner/repo or owner/repo/subdir (local paths are CLI-only)", "plugininstaller.source.invalid"),
49
+ };
50
+ const args = ["install", source, "--name", id, "--data-dir", dataDir];
51
+ if (spec.action === "update")
52
+ args.push("--force");
53
+ if (spec.build)
54
+ args.push("--build");
55
+ return { args };
56
+ }
57
+ export class PluginInstaller {
58
+ deps;
59
+ child = null;
60
+ currentJobId = null;
61
+ constructor(deps) {
62
+ this.deps = deps;
63
+ }
64
+ /** 正在跑的作业 id(null = 空闲)。 */
65
+ get busyJobId() {
66
+ return this.currentJobId;
67
+ }
68
+ /** 启动一个作业;返回 `{ ok: true }` 或 `{ error }`(拒绝原因,已本地化)。 */
69
+ start(spec, hooks) {
70
+ const l = hooks.lang?.() ?? "en";
71
+ if (this.deps.managed)
72
+ return {
73
+ ok: false,
74
+ error: pick(l, "本实例由部署方托管(PI_WEB_MANAGED=1):插件安装/更新由部署流程负责,界面不提供入口。", "This instance is managed (PI_WEB_MANAGED=1): plugin installs are handled by whoever deploys it.", "plugininstaller.managed"),
75
+ };
76
+ const built = buildPluginJobArgs(spec, this.deps.dataDir, hooks.lang);
77
+ if ("error" in built)
78
+ return { ok: false, error: built.error };
79
+ if (this.child)
80
+ return {
81
+ ok: false,
82
+ error: pick(l, "已有一个插件作业在运行,等它结束(或取消)后再试。", "Another plugin job is already running — wait for it (or cancel it) and try again.", "plugininstaller.busy"),
83
+ };
84
+ const binPath = join(this.deps.pkgRoot, "bin", "pi-web-ui.mjs");
85
+ if (!existsSync(binPath))
86
+ return {
87
+ ok: false,
88
+ error: pick(l, `找不到 pi-web-ui 命令行入口(${binPath}),无法执行插件操作。`, `pi-web-ui CLI not found at ${binPath} — cannot run the plugin operation.`, "plugininstaller.cli.missing", { path: binPath }),
89
+ };
90
+ const { jobId, action, id: pluginId } = spec;
91
+ const emit = (msg) => {
92
+ try {
93
+ hooks.emit(msg);
94
+ }
95
+ catch {
96
+ /* socket 已死:作业继续跑,只是没人看进度 */
97
+ }
98
+ };
99
+ const child = spawn(process.execPath, [binPath, ...built.args], {
100
+ windowsHide: true,
101
+ // POSIX 下自成进程组:取消/看门狗要杀的是整棵树,killPidTree 用 -pid
102
+ // 干活需要这一点(与 plugin-project.ts 同一写法)。缺了它,Linux/macOS
103
+ // 上 kill(-pid) 指向不存在的组而静默失败,作业杀不掉、锁一直占着
104
+ // (CI 的 busy 单测在 Linux 上 5 秒超时,Windows 走 taskkill 不受影响)。
105
+ detached: process.platform !== "win32",
106
+ env: { ...process.env, PI_WEB_DATA_DIR: this.deps.dataDir, NO_COLOR: "1" },
107
+ stdio: ["ignore", "pipe", "pipe"],
108
+ });
109
+ this.child = child;
110
+ this.currentJobId = jobId;
111
+ let tail = "";
112
+ let pending = "";
113
+ let finished = false;
114
+ let cancelled = false;
115
+ let timedOut = false;
116
+ const pushLine = (raw) => {
117
+ const line = raw.replace(/\r$/, "");
118
+ if (!line.trim())
119
+ return;
120
+ tail = (tail + line + "\n").slice(-MAX_OUTPUT_CHARS);
121
+ emit({ type: "plugin_job", jobId, action, pluginId, phase: "log", line: line.slice(0, MAX_LINE_CHARS) });
122
+ };
123
+ const onChunk = (chunk) => {
124
+ pending += chunk.toString();
125
+ let idx;
126
+ while ((idx = pending.indexOf("\n")) >= 0) {
127
+ pushLine(pending.slice(0, idx));
128
+ pending = pending.slice(idx + 1);
129
+ }
130
+ while (pending.length > MAX_LINE_CHARS) {
131
+ pushLine(pending.slice(0, MAX_LINE_CHARS));
132
+ pending = pending.slice(MAX_LINE_CHARS);
133
+ }
134
+ };
135
+ const timer = setTimeout(() => {
136
+ timedOut = true;
137
+ this.killTree(child);
138
+ }, Math.max(1000, Number(this.deps.timeoutMs ?? DEFAULT_TIMEOUT_MS)));
139
+ const finish = (ok, error) => {
140
+ if (finished)
141
+ return;
142
+ finished = true;
143
+ clearTimeout(timer);
144
+ if (pending.trim())
145
+ pushLine(pending);
146
+ pending = "";
147
+ this.child = null;
148
+ this.currentJobId = null;
149
+ const out = tail;
150
+ emit({
151
+ type: "plugin_job",
152
+ jobId,
153
+ action,
154
+ pluginId,
155
+ phase: "done",
156
+ ok,
157
+ ...(error ? { error } : {}),
158
+ output: out,
159
+ });
160
+ void Promise.resolve(hooks.done(ok, { error, output: out })).catch(() => {
161
+ /* 后处理(重扫/重推)失败不该影响已经结束的作业本身 */
162
+ });
163
+ };
164
+ child.stdout?.on("data", onChunk);
165
+ child.stderr?.on("data", onChunk);
166
+ child.on("error", (err) => finish(false, err.message));
167
+ child.on("close", (code) => {
168
+ if (cancelled)
169
+ return finish(false, pick(l, "作业已取消", "Job cancelled", "plugininstaller.cancelled"));
170
+ if (timedOut)
171
+ return finish(false, pick(l, `作业超时(${Math.round(Math.max(1000, Number(this.deps.timeoutMs ?? DEFAULT_TIMEOUT_MS)) / 60000)} 分钟)已终止`, `Job timed out after ${Math.round(Math.max(1000, Number(this.deps.timeoutMs ?? DEFAULT_TIMEOUT_MS)) / 60000)} min and was killed`, "plugininstaller.timeout"));
172
+ finish(code === 0, code === 0 ? undefined : `exit code ${code}`);
173
+ });
174
+ emit({ type: "plugin_job", jobId, action, pluginId, phase: "start" });
175
+ // cancel 需要拿到 cancelled 标志:挂在实例上由 cancel() 设置。
176
+ this.cancelFlags.set(jobId, () => {
177
+ cancelled = true;
178
+ });
179
+ return { ok: true };
180
+ }
181
+ /** cancel 时置位的标志(jobId → setter),作业结束后清理。 */
182
+ cancelFlags = new Map();
183
+ /**
184
+ * 同步跑一个作业并等它结束(目录同步的自动安装用,见 plugin-catalog-sync.ts)。
185
+ *
186
+ * 与 start() 同一条路径、同一把锁:只是把「结束后 resolve」包成 Promise。若已有
187
+ * 别的作业在跑,会直接返回拒绝原因(不排队)——安装器不是队列,调用方自己决定重试。
188
+ */
189
+ run(spec, opts) {
190
+ return new Promise((resolve) => {
191
+ const started = this.start(spec, {
192
+ lang: opts?.lang,
193
+ emit: (msg) => {
194
+ if (msg.type === "plugin_job" && msg.phase === "log" && msg.line)
195
+ opts?.onLine?.(msg.line);
196
+ },
197
+ done: (ok, info) => resolve({ ok, ...(info.error ? { error: info.error } : {}), output: info.output }),
198
+ });
199
+ if (!started.ok)
200
+ resolve({ ok: false, error: started.error, output: "" });
201
+ });
202
+ }
203
+ /** 取消指定作业(杀掉整棵进程树)。返回是否命中。 */
204
+ cancel(jobId) {
205
+ if (!this.currentJobId || this.currentJobId !== jobId || !this.child)
206
+ return false;
207
+ this.cancelFlags.get(jobId)?.();
208
+ this.killTree(this.child);
209
+ return true;
210
+ }
211
+ /** 杀进程树:Windows 走 taskkill /T,POSIX 走进程组(见 process-utils)。 */
212
+ killTree(child) {
213
+ const pid = child.pid;
214
+ if (!pid)
215
+ return;
216
+ try {
217
+ killPidTree(pid);
218
+ }
219
+ catch {
220
+ try {
221
+ child.kill("SIGKILL");
222
+ }
223
+ catch {
224
+ /* 已经退出了 */
225
+ }
226
+ }
227
+ }
228
+ /** 关机:杀掉在跑的作业(不留孤儿 install 进程)。 */
229
+ dispose() {
230
+ const child = this.child;
231
+ if (child) {
232
+ this.cancelFlags.forEach((f) => f());
233
+ this.killTree(child);
234
+ }
235
+ this.child = null;
236
+ this.currentJobId = null;
237
+ }
238
+ }