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,302 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * pi-proactive 常驻助理:**跨平台一键卸载**(幂等 · 卸载后**零残留**)。
4
+ *
5
+ * 与 `tools/daemon-install.mjs` **对称**(同一份平台口径,见 `tools/daemon-common.mjs`):
6
+ * · macOS → launchctl bootout/unload + 删 plist
7
+ * · Linux → systemctl --user disable --now + 删 unit
8
+ * · Windows → schtasks /Delete /TN <任务> /F + taskkill + 删目录
9
+ *
10
+ * 做三件事(顺序固定):
11
+ * ① 停(宿主 + 残留进程;监督器也要杀,否则它会把值班进程再拉起来)
12
+ * ② 删(单元 / 任务 / 日志 / socket / 状态目录)
13
+ * ③ 校验(**逐项列出证据**;有残留即非零退出 —— 不谎报「卸载成功」)
14
+ *
15
+ * 保留(默认不删):`<agentDir>/proactive/` 下的任务表 / 事件流 / 邮箱(**用户数据**,
16
+ * 删不删由用户决定;`--purge-data` 才连它一起删)。
17
+ *
18
+ * 用法:
19
+ * node pi-proactive/tools/daemon-uninstall.mjs
20
+ * node pi-proactive/tools/daemon-uninstall.mjs --agent-dir <dir>
21
+ * node pi-proactive/tools/daemon-uninstall.mjs --label <label> # launchd 单元标签
22
+ * node pi-proactive/tools/daemon-uninstall.mjs --service-name <n> # systemd 单元名
23
+ * node pi-proactive/tools/daemon-uninstall.mjs --task-name <n> # Windows 计划任务名
24
+ * node pi-proactive/tools/daemon-uninstall.mjs --purge-data # 连用户数据一起删
25
+ * node pi-proactive/tools/daemon-uninstall.mjs --print-plan # 只打印卸载计划(JSON)
26
+ *
27
+ * 退出码:0=卸载完成且无残留;1=有残留;2=环境不支持 / 用法错误。
28
+ */
29
+ import fs from "node:fs";
30
+ import os from "node:os";
31
+ import path from "node:path";
32
+ import {
33
+ defaultAgentDir,
34
+ defaultAgentId,
35
+ detectPlatform,
36
+ isAlive,
37
+ pkgRootFrom,
38
+ readJsonSafe,
39
+ resolveSocketPath,
40
+ run,
41
+ } from "./daemon-common.mjs";
42
+
43
+ const PKG_ROOT = pkgRootFrom(import.meta.url);
44
+
45
+ const USAGE = `pi-proactive 常驻助理 · 跨平台卸载器(macOS / Linux / Windows)
46
+
47
+ node pi-proactive/tools/daemon-uninstall.mjs [选项]
48
+
49
+ --agent-dir <dir> agent 根目录(默认 ~/.pi/agent)
50
+ --agent-id <id> 稳定身份(缺省从 host.json 读)
51
+ --label <label> launchd 单元标签(默认 com.pi-proactive.daemon)
52
+ --service-name <n> systemd 单元名(默认 pi-proactive-daemon.service)
53
+ --task-name <n> Windows 计划任务名(默认 PiProactiveDaemon)
54
+ --purge-data 连用户数据(任务表 / 事件流 / 邮箱)一起删
55
+ --print-plan 只打印卸载计划(JSON),不产生任何副作用
56
+ -h, --help 本帮助
57
+ `;
58
+
59
+ /** 解析命令行。 */
60
+ function parseArgs(argv) {
61
+ const o = {
62
+ agentDir: defaultAgentDir(),
63
+ agentId: "",
64
+ label: process.env.PI_PROACTIVE_DAEMON_LABEL || "com.pi-proactive.daemon",
65
+ serviceName: process.env.PI_PROACTIVE_DAEMON_SERVICE || "pi-proactive-daemon.service",
66
+ taskName: process.env.PI_PROACTIVE_DAEMON_TASK || "PiProactiveDaemon",
67
+ purgeData: false,
68
+ printPlan: false,
69
+ platform: "",
70
+ };
71
+ for (let i = 0; i < argv.length; i++) {
72
+ const a = argv[i];
73
+ const next = () => {
74
+ const v = argv[++i];
75
+ if (v === undefined) {
76
+ console.error(`参数 ${a} 缺值`);
77
+ process.exit(2);
78
+ }
79
+ return v;
80
+ };
81
+ if (a === "--agent-dir") o.agentDir = path.resolve(next());
82
+ else if (a === "--agent-id") o.agentId = next();
83
+ else if (a === "--label") o.label = next();
84
+ else if (a === "--service-name") o.serviceName = next();
85
+ else if (a === "--task-name") o.taskName = next();
86
+ else if (a === "--purge-data") o.purgeData = true;
87
+ else if (a === "--print-plan") o.printPlan = true;
88
+ else if (a === "--platform") o.platform = next();
89
+ else if (a === "-h" || a === "--help") {
90
+ console.log(USAGE);
91
+ process.exit(0);
92
+ } else {
93
+ console.error(`未知参数:${a}`);
94
+ process.exit(2);
95
+ }
96
+ }
97
+ return o;
98
+ }
99
+
100
+ /** 生成卸载计划(纯计算)。 */
101
+ function buildPlan(opts) {
102
+ const platform = detectPlatform(opts.platform);
103
+ const daemonDir = path.join(opts.agentDir, "proactive", "daemon");
104
+ const proactiveDir = path.join(opts.agentDir, "proactive");
105
+ const hostStateFile = path.join(daemonDir, "host.json");
106
+ const hbFile = path.join(daemonDir, "heartbeat.json");
107
+ const lockFile = path.join(daemonDir, "daemon.lock", "pid");
108
+ const hostState = readJsonSafe(hostStateFile);
109
+ const agentId = opts.agentId || (hostState && hostState.agentId) || defaultAgentId();
110
+ const socketInfo = resolveSocketPath(PKG_ROOT, agentId, opts.agentDir);
111
+
112
+ const steps = [];
113
+ let unitPath = "";
114
+
115
+ if (platform === "darwin") {
116
+ unitPath = path.join(os.homedir(), "Library", "LaunchAgents", `${opts.label}.plist`);
117
+ steps.push({
118
+ kind: "exec",
119
+ cmd: "launchctl",
120
+ args: ["bootout", `gui/${process.getuid?.() ?? 0}/${opts.label}`],
121
+ ignoreFail: true,
122
+ });
123
+ steps.push({ kind: "exec", cmd: "launchctl", args: ["unload", unitPath], ignoreFail: true });
124
+ steps.push({ kind: "rm", path: unitPath });
125
+ } else if (platform === "linux") {
126
+ unitPath = path.join(os.homedir(), ".config", "systemd", "user", opts.serviceName);
127
+ steps.push({ kind: "exec", cmd: "systemctl", args: ["--user", "disable", "--now", opts.serviceName], ignoreFail: true });
128
+ steps.push({ kind: "exec", cmd: "systemctl", args: ["--user", "reset-failed", opts.serviceName], ignoreFail: true });
129
+ steps.push({ kind: "rm", path: unitPath });
130
+ steps.push({ kind: "exec", cmd: "systemctl", args: ["--user", "daemon-reload"], ignoreFail: true });
131
+ } else if (platform === "win32") {
132
+ unitPath = `计划任务:${opts.taskName}`;
133
+ steps.push({ kind: "exec", cmd: "schtasks", args: ["/Delete", "/TN", opts.taskName, "/F"], ignoreFail: true });
134
+ // 杀残留进程(Windows 用 taskkill;unix 用 kill)
135
+ steps.push({
136
+ kind: "killPids",
137
+ cmd: platform === "win32" ? "taskkill" : "kill",
138
+ args: platform === "win32" ? ["/F", "/PID", "{pid}"] : ["-TERM", "{pid}"],
139
+ sources: [hbFile, lockFile],
140
+ ignoreFail: true,
141
+ });
142
+ } else {
143
+ throw new Error(`不支持的操作系统:${process.platform}(本卸载器支持 macOS / Linux / Windows)`);
144
+ }
145
+
146
+ if (platform !== "win32") {
147
+ steps.push({
148
+ kind: "killPids",
149
+ cmd: "kill",
150
+ args: ["-TERM", "{pid}"],
151
+ sources: [hbFile, lockFile],
152
+ ignoreFail: true,
153
+ });
154
+ }
155
+
156
+ steps.push({ kind: "rm", path: daemonDir });
157
+ if (socketInfo?.path) steps.push({ kind: "rm", path: socketInfo.path });
158
+ if (opts.purgeData) steps.push({ kind: "rm", path: proactiveDir });
159
+
160
+ return {
161
+ platform,
162
+ agentDir: opts.agentDir,
163
+ agentId,
164
+ label: opts.label,
165
+ serviceName: opts.serviceName,
166
+ taskName: opts.taskName,
167
+ hostKind: platform === "win32" ? "schtasks" : platform === "darwin" ? "launchd" : "systemd",
168
+ unitPath,
169
+ daemonDir,
170
+ proactiveDir,
171
+ hostStateFile,
172
+ heartbeatFile: hbFile,
173
+ socketPath: socketInfo?.path ?? "",
174
+ socketBytes: socketInfo?.bytes ?? -1,
175
+ purgeData: opts.purgeData,
176
+ steps,
177
+ };
178
+ }
179
+
180
+ /** 执行计划。 */
181
+ function executePlan(plan) {
182
+ for (const step of plan.steps) {
183
+ switch (step.kind) {
184
+ case "rm":
185
+ fs.rmSync(step.path, { recursive: true, force: true });
186
+ break;
187
+ case "exec": {
188
+ const r = run(step.cmd, step.args, { cwd: plan.agentDir });
189
+ if (!r.ok && !step.ignoreFail) {
190
+ throw new Error(`${step.cmd} ${step.args.join(" ")} 失败:${r.stderr || r.stdout || r.error}`);
191
+ }
192
+ break;
193
+ }
194
+ case "killPids": {
195
+ // 从心跳(值班进程)与锁(监督器)里取 pid → 全杀掉
196
+ const pids = new Set();
197
+ for (const src of step.sources ?? []) {
198
+ try {
199
+ const raw = fs.readFileSync(src, "utf8").trim();
200
+ const pid = Number(raw.startsWith("{") ? (JSON.parse(raw).pid ?? 0) : raw) || 0;
201
+ if (pid > 0) pids.add(pid);
202
+ } catch {
203
+ /* 文件不在 → 没有 pid */
204
+ }
205
+ }
206
+ for (const pid of pids) {
207
+ if (!isAlive(pid)) continue;
208
+ const args = step.args.map((a) => (a === "{pid}" ? String(pid) : a));
209
+ run(step.cmd, args, { cwd: plan.agentDir });
210
+ }
211
+ break;
212
+ }
213
+ default:
214
+ throw new Error(`未知步骤:${step.kind}`);
215
+ }
216
+ }
217
+ }
218
+
219
+ /** 睡一会儿。 */
220
+ function sleep(ms) {
221
+ return new Promise((r) => setTimeout(r, ms));
222
+ }
223
+
224
+ /** 校验零残留(逐项;有残留即非零退出)。 */
225
+ async function verifyResidue(plan) {
226
+ const residue = [];
227
+ const check = (gone, what) => {
228
+ if (!gone) residue.push(what);
229
+ };
230
+ if (plan.platform === "darwin") {
231
+ const r = run("launchctl", ["print", `gui/${process.getuid?.() ?? 0}/${plan.label}`]);
232
+ check(!r.ok, `服务仍处于加载状态(launchctl print 成功):${plan.label}`);
233
+ } else if (plan.platform === "linux") {
234
+ const r = run("systemctl", ["--user", "is-enabled", plan.serviceName]);
235
+ check(!r.ok, `服务仍处于 enabled 状态:${plan.serviceName}`);
236
+ } else {
237
+ const r = run("schtasks", ["/Query", "/TN", plan.taskName]);
238
+ check(!r.ok, `计划任务仍存在:${plan.taskName}`);
239
+ }
240
+ check(!fs.existsSync(plan.daemonDir), `状态/日志目录仍在:${plan.daemonDir}`);
241
+ if (plan.socketPath) check(!fs.existsSync(plan.socketPath), `socket 端点仍在:${plan.socketPath}`);
242
+ // 进程:心跳/锁里的 pid 必须已经不在
243
+ for (const src of [plan.heartbeatFile, path.join(plan.daemonDir, "daemon.lock", "pid")]) {
244
+ let pid = 0;
245
+ try {
246
+ const raw = fs.readFileSync(src, "utf8").trim();
247
+ pid = Number(raw.startsWith("{") ? (JSON.parse(raw).pid ?? 0) : raw) || 0;
248
+ } catch {
249
+ continue;
250
+ }
251
+ if (pid > 0 && isAlive(pid)) residue.push(`进程还在(pid=${pid})`);
252
+ }
253
+ await sleep(500);
254
+ return residue;
255
+ }
256
+
257
+ // ------------------------------------------------------------------ 入口 ----
258
+
259
+ const opts = parseArgs(process.argv.slice(2));
260
+ let plan;
261
+ try {
262
+ plan = buildPlan(opts);
263
+ } catch (e) {
264
+ console.error(String(e instanceof Error ? e.message : e));
265
+ process.exit(2);
266
+ }
267
+
268
+ if (opts.printPlan) {
269
+ process.stdout.write(`${JSON.stringify(plan, null, 2)}\n`);
270
+ process.exit(0);
271
+ }
272
+
273
+ try {
274
+ executePlan(plan);
275
+ if (plan.purgeData) {
276
+ console.log(`已删用户数据(--purge-data):${plan.proactiveDir}`);
277
+ } else {
278
+ console.log(`保留(未删):${plan.proactiveDir} 下的任务表 / 事件流 / 邮箱(用户数据;--purge-data 才删)`);
279
+ }
280
+ const residue = await verifyResidue(plan);
281
+ if (residue.length > 0) {
282
+ console.error("结果:存在残留(见上方)");
283
+ for (const r of residue) console.error(` · ${r}`);
284
+ console.error("手工清理命令(逐条执行即可清干净):");
285
+ if (plan.platform === "darwin") {
286
+ console.error(` launchctl bootout "gui/${process.getuid?.() ?? 0}/${plan.label}" || true`);
287
+ console.error(` rm -f "${plan.unitPath}"`);
288
+ } else if (plan.platform === "linux") {
289
+ console.error(` systemctl --user disable --now "${plan.serviceName}" || true`);
290
+ console.error(` rm -f "${plan.unitPath}"`);
291
+ } else {
292
+ console.error(` schtasks /Delete /TN "${plan.taskName}" /F || true`);
293
+ }
294
+ console.error(` rm -rf "${plan.daemonDir}"`);
295
+ process.exit(1);
296
+ }
297
+ console.log("结果:卸载完成,无残留(exit 0)");
298
+ process.exit(0);
299
+ } catch (e) {
300
+ console.error(String(e instanceof Error ? e.message : e));
301
+ process.exit(1);
302
+ }