pi-proactive 0.2.0-beta.4 → 0.2.0-beta.6

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,289 @@
1
+ /**
2
+ * pi-proactive 常驻助理(**跨平台用户侧入口**)的共享底座。
3
+ *
4
+ * 为什么要这个文件:`tools/daemon-install.mjs`、`tools/daemon-uninstall.mjs`、
5
+ * `tools/verify-windows.mjs` 三者共用「平台判定 / 路径解析 / 模板渲染 / 命令执行」——
6
+ * **同一口径只写一份**(避免安装器与卸载器对「端点路径」「单元位置」的理解漂移)。
7
+ *
8
+ * 硬约束:
9
+ * · 零第三方依赖(只用 Node 内置模块);
10
+ * · 纯 ESM(`.mjs`)—— **不是 TypeScript**:`.mjs` 里不写类型注解(类型信息走 JSDoc),
11
+ * 否则 Node 直接语法报错;包内零 .ts 同样是发布纪律 W6;
12
+ * · Windows / macOS / Linux 同一份代码。
13
+ */
14
+ import fs from "node:fs";
15
+ import os from "node:os";
16
+ import path from "node:path";
17
+ import { spawnSync } from "node:child_process";
18
+ import { fileURLToPath, pathToFileURL } from "node:url";
19
+
20
+ /** unix socket 端点路径上限(与 `src/socket.ts` 的 MAX_SOCKET_PATH_BYTES 逐字一致)。 */
21
+ export const MAX_SOCKET_PATH_BYTES = 100;
22
+
23
+ /**
24
+ * 平台判定。
25
+ *
26
+ * @param {string} [override] 显式平台(`win32` / `darwin` / `linux`)—— 供计划打印与测试用;
27
+ * 缺省读环境变量 `PI_PROACTIVE_PLATFORM`,再缺省用真实平台。
28
+ * @returns {string}
29
+ */
30
+ export function detectPlatform(override = "") {
31
+ const raw = String(override || process.env.PI_PROACTIVE_PLATFORM || "").toLowerCase();
32
+ if (raw === "win32" || raw === "windows") return "win32";
33
+ if (raw === "darwin" || raw === "macos") return "darwin";
34
+ if (raw === "linux") return "linux";
35
+ if (process.platform === "win32") return "win32";
36
+ if (process.platform === "darwin") return "darwin";
37
+ if (process.platform === "linux") return "linux";
38
+ return process.platform;
39
+ }
40
+
41
+ /**
42
+ * 包根目录(tools/ 的上一级)。
43
+ * @param {string} importMetaUrl
44
+ * @returns {string}
45
+ */
46
+ export function pkgRootFrom(importMetaUrl) {
47
+ return path.resolve(path.dirname(fileURLToPath(importMetaUrl)), "..");
48
+ }
49
+
50
+ /**
51
+ * agent 根目录(默认 `~/.pi/agent`;可用 `PI_CODING_AGENT_DIR` 覆盖)。
52
+ * @returns {string}
53
+ */
54
+ export function defaultAgentDir() {
55
+ return process.env.PI_CODING_AGENT_DIR || path.join(os.homedir(), ".pi", "agent");
56
+ }
57
+
58
+ /**
59
+ * 稳定身份(跨重启连续;缺省按主机名派生)。
60
+ * @returns {string}
61
+ */
62
+ export function defaultAgentId() {
63
+ return `employee#${os.hostname()}#pi-proactive-daemon`;
64
+ }
65
+
66
+ /**
67
+ * 常驻入口(值班进程本体)。
68
+ *
69
+ * 开发态(仓库):`src/daemon.ts` 是真源 → 优先;
70
+ * 产物态(npm 包):包里**没有** src/ → 落到 `build/src/daemon.js`。
71
+ * 候选清单与错误信息同源 → 报错时说的就是真的试过的路径。
72
+ *
73
+ * @param {string} pkgRoot
74
+ * @returns {{ entry: string; needsStripTypes: boolean } | null}
75
+ */
76
+ export function resolveDaemonEntry(pkgRoot) {
77
+ for (const c of ["src/daemon.ts", "build/src/daemon.js"]) {
78
+ const p = path.join(pkgRoot, c);
79
+ if (fs.existsSync(p)) return { entry: p, needsStripTypes: p.endsWith(".ts") };
80
+ }
81
+ return null;
82
+ }
83
+
84
+ /**
85
+ * 端点(socket)路径 —— **复用 `src/socket.ts` 的实现**(不复制口径)。
86
+ *
87
+ * 用子进程 import 源码模块:`.ts` 走 Node 的类型擦除,`.js` 直接跑;
88
+ * 产物态与开发态都指向同一份逻辑 → 不会算错。
89
+ *
90
+ * @param {string} pkgRoot
91
+ * @param {string} agentId
92
+ * @param {string} agentDir
93
+ * @returns {{ path: string; bytes: number } | null}
94
+ */
95
+ export function resolveSocketPath(pkgRoot, agentId, agentDir) {
96
+ const mod = ["src/socket.ts", "build/src/socket.js"]
97
+ .map((c) => path.join(pkgRoot, c))
98
+ .find((p) => fs.existsSync(p));
99
+ if (!mod) return null;
100
+ const code =
101
+ `import(${JSON.stringify(pathToFileURL(mod).href)})` +
102
+ `.then((m)=>process.stdout.write(m.socketPathFor(process.argv[1], process.argv[2])))` +
103
+ `.catch(()=>process.exit(3))`;
104
+ const res = spawnSync(process.execPath, ["--experimental-strip-types", "-e", code, agentId, agentDir], {
105
+ encoding: "utf8",
106
+ timeout: 20_000,
107
+ });
108
+ if (res.status !== 0) return null;
109
+ const p = (res.stdout ?? "").trim();
110
+ if (!p) return null;
111
+ return { path: p, bytes: Buffer.byteLength(p, "utf8") };
112
+ }
113
+
114
+ /**
115
+ * 同步跑一条命令(不继承 stdin;带超时守卫)。
116
+ *
117
+ * @param {string} cmd
118
+ * @param {string[]} args
119
+ * @param {{ cwd?: string; timeout?: number }} [opts]
120
+ */
121
+ export function run(cmd, args, opts = {}) {
122
+ const res = spawnSync(cmd, args, {
123
+ encoding: "utf8",
124
+ timeout: opts.timeout ?? 60_000,
125
+ cwd: opts.cwd,
126
+ stdio: ["ignore", "pipe", "pipe"],
127
+ });
128
+ return {
129
+ code: res.status ?? (res.error ? 1 : 0),
130
+ ok: res.status === 0 && !res.error,
131
+ stdout: res.stdout ?? "",
132
+ stderr: res.stderr ?? "",
133
+ error: res.error ? String(res.error.message) : "",
134
+ };
135
+ }
136
+
137
+ /**
138
+ * 进程是否还活着(pid 无效 → false)。
139
+ * @param {number | undefined | null} pid
140
+ * @returns {boolean}
141
+ */
142
+ export function isAlive(pid) {
143
+ if (!pid || pid <= 0) return false;
144
+ try {
145
+ process.kill(pid, 0);
146
+ return true;
147
+ } catch {
148
+ return false;
149
+ }
150
+ }
151
+
152
+ /**
153
+ * 读 JSON(坏数据不抛 → 返回 null;巡检脚本绝不能被脏输入搞崩)。
154
+ * @param {string} file
155
+ */
156
+ export function readJsonSafe(file) {
157
+ try {
158
+ return JSON.parse(fs.readFileSync(file, "utf8"));
159
+ } catch {
160
+ return null;
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Windows 命令行参数引号化(含空格/引号 → 必须加引号)。
166
+ * @param {string} value
167
+ * @returns {string}
168
+ */
169
+ export function quoteWindows(value) {
170
+ return `"${String(value).replace(/"/g, '\\"')}"`;
171
+ }
172
+
173
+ /**
174
+ * Windows 计划任务的可执行命令串(`schtasks /TR` 的值)。
175
+ *
176
+ * 关键:**路径含空格必须引号化**(`C:\Program Files\nodejs\node.exe`)。
177
+ *
178
+ * @param {{ node: string; runner: string; extraArgs?: string[] }} o
179
+ * @returns {string}
180
+ */
181
+ export function buildWindowsTaskCommand(o) {
182
+ const parts = [quoteWindows(o.node), quoteWindows(o.runner), ...(o.extraArgs ?? []).map(quoteWindows)];
183
+ return parts.join(" ");
184
+ }
185
+
186
+ /**
187
+ * systemd 的参数引号化(路径含空格 → 加引号)。
188
+ * @param {string} value
189
+ * @returns {string}
190
+ */
191
+ export function quoteSystemd(value) {
192
+ const s = String(value);
193
+ return /[\s"']/.test(s) ? `"${s.replace(/"/g, '\\"')}"` : s;
194
+ }
195
+
196
+ /**
197
+ * XML 文本转义(plist 里路径可能带 & < >)。
198
+ * @param {string} value
199
+ * @returns {string}
200
+ */
201
+ export function xmlEscape(value) {
202
+ return String(value)
203
+ .replace(/&/g, "&amp;")
204
+ .replace(/</g, "&lt;")
205
+ .replace(/>/g, "&gt;");
206
+ }
207
+
208
+ /**
209
+ * 渲染 launchd 单元(**复用 `daemon/launchd.plist` 模板,不改模板本身**)。
210
+ *
211
+ * 只做两件事:
212
+ * ① 占位符替换(label / pkgRoot / agentId / logDir / runner);
213
+ * ② 把 `ProgramArguments` 换成**直接调用 node**(模板里是 `/bin/bash` →
214
+ * 包内不得依赖 bash;unix 侧 bash 仍在,但统一走 node 才能三平台同源)。
215
+ *
216
+ * @param {string} template
217
+ * @param {{ label: string; pkgRoot: string; agentId: string; logDir: string; runner: string; programArgs: string[] }} o
218
+ * @returns {string}
219
+ */
220
+ export function renderLaunchdPlist(template, o) {
221
+ let out = template
222
+ .replace(/__LABEL__/g, o.label)
223
+ .replace(/__PKG_ROOT__/g, o.pkgRoot)
224
+ .replace(/__AGENT_ID__/g, o.agentId)
225
+ .replace(/__LOG_DIR__/g, o.logDir)
226
+ .replace(/__RUNNER__/g, o.runner);
227
+ const entries = o.programArgs.map((a) => `\t\t<string>${xmlEscape(a)}</string>`).join("\n");
228
+ out = out.replace(
229
+ /(<key>ProgramArguments<\/key>\s*<array>)[\s\S]*?(<\/array>)/,
230
+ (_m, head, tail) => `${head}\n${entries}\n\t${tail}`,
231
+ );
232
+ return out;
233
+ }
234
+
235
+ /**
236
+ * 渲染 systemd --user 单元(**复用 `daemon/pi-proactive-daemon.service` 模板**)。
237
+ *
238
+ * 与 plist 同理:`ExecStart` 换成直接调用 node(不再经 bash)。
239
+ *
240
+ * @param {string} template
241
+ * @param {{ pkgRoot: string; agentId: string; workDir: string; execStart: string }} o
242
+ * @returns {string}
243
+ */
244
+ export function renderSystemdUnit(template, o) {
245
+ return template
246
+ .split("\n")
247
+ .map((line) => {
248
+ if (line.startsWith("ExecStart=")) return `ExecStart=${o.execStart}`;
249
+ if (line.startsWith("WorkingDirectory=")) return `WorkingDirectory=${o.workDir || o.pkgRoot}`;
250
+ if (line.startsWith("Environment=PI_PROACTIVE_AGENT_ID=")) {
251
+ return `Environment=PI_PROACTIVE_AGENT_ID=${o.agentId}`;
252
+ }
253
+ return line;
254
+ })
255
+ .join("\n");
256
+ }
257
+
258
+ /**
259
+ * 选一个**够短**的 agent 根(socket 端点路径 < 100B 的硬约束)。
260
+ *
261
+ * @param {string[]} candidates 候选根(按优先级)
262
+ * @param {(dir: string) => number | null} measure 量一条候选的端点路径字节数(null = 量不出来)
263
+ * @returns {{ dir: string; bytes: number } | null}
264
+ */
265
+ export function chooseShortAgentDir(candidates, measure) {
266
+ for (const dir of candidates) {
267
+ const bytes = measure(dir);
268
+ if (bytes !== null && bytes <= MAX_SOCKET_PATH_BYTES) return { dir, bytes };
269
+ }
270
+ return null;
271
+ }
272
+
273
+ /**
274
+ * 心跳文件路径(与 `src/daemon-config.ts` 的 heartbeatPath 同口径)。
275
+ * @param {string} agentDir
276
+ * @returns {string}
277
+ */
278
+ export function heartbeatPathFor(agentDir) {
279
+ return path.join(agentDir, "proactive", "daemon", "heartbeat.json");
280
+ }
281
+
282
+ /**
283
+ * 值班锁目录(与 `daemon/run-daemon.mjs` 同口径)。
284
+ * @param {string} agentDir
285
+ * @returns {string}
286
+ */
287
+ export function lockPathFor(agentDir) {
288
+ return path.join(agentDir, "proactive", "daemon", "daemon.lock");
289
+ }