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.
- package/CHANGELOG.md +77 -0
- package/README.md +32 -14
- package/build/extensions/proactive.js +2 -1
- package/daemon/run-daemon.mjs +268 -0
- package/package.json +12 -3
- package/skills/pi-proactive/SKILL.md +67 -9
- package/tools/daemon-common.mjs +289 -0
- package/tools/daemon-install.mjs +488 -0
- package/tools/daemon-uninstall.mjs +302 -0
- package/tools/enumerate-residue.mjs +487 -0
- package/tools/verify-windows.mjs +358 -0
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* pi-proactive **Windows 验收脚本**(W1..W6 逐项证据表)。
|
|
4
|
+
*
|
|
5
|
+
* 判据来源:`docs/project/windows-porting.md` §四(验收基准 W1..W6)。
|
|
6
|
+
* 纪律:
|
|
7
|
+
* · **真机结论必须来自那台 Windows 机器** —— 非 Windows 上跑只做离线自检,
|
|
8
|
+
* 真机项一律标「**未验证**」(不得谎报通过);
|
|
9
|
+
* · 每项都给出**可核证据**(路径 / 字节数 / 命令输出 / 进程 pid),不是「应该能行」。
|
|
10
|
+
*
|
|
11
|
+
* 用法(Windows 真机):
|
|
12
|
+
* node pi-proactive/tools/verify-windows.mjs # 只验收(要求已安装 + 已常驻)
|
|
13
|
+
* node pi-proactive/tools/verify-windows.mjs --install # 先装再验收(一条命令)
|
|
14
|
+
* node pi-proactive/tools/verify-windows.mjs --check-residue # 只查卸载残留
|
|
15
|
+
* node pi-proactive/tools/verify-windows.mjs --selftest # 非 Windows 上的离线自检
|
|
16
|
+
*
|
|
17
|
+
* 退出码:0=无失败项(可能仍有未验证项);1=有失败项;2=用法错误。
|
|
18
|
+
*/
|
|
19
|
+
import fs from "node:fs";
|
|
20
|
+
import os from "node:os";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
import { fileURLToPath } from "node:url";
|
|
23
|
+
import {
|
|
24
|
+
MAX_SOCKET_PATH_BYTES,
|
|
25
|
+
defaultAgentDir,
|
|
26
|
+
defaultAgentId,
|
|
27
|
+
detectPlatform,
|
|
28
|
+
isAlive,
|
|
29
|
+
pkgRootFrom,
|
|
30
|
+
readJsonSafe,
|
|
31
|
+
resolveSocketPath,
|
|
32
|
+
run,
|
|
33
|
+
} from "./daemon-common.mjs";
|
|
34
|
+
|
|
35
|
+
const PKG_ROOT = pkgRootFrom(import.meta.url);
|
|
36
|
+
|
|
37
|
+
const USAGE = `pi-proactive Windows 验收(${"W1..W6"})
|
|
38
|
+
|
|
39
|
+
node pi-proactive/tools/verify-windows.mjs [选项]
|
|
40
|
+
|
|
41
|
+
--agent-dir <dir> agent 根目录(默认 ~/.pi/agent)
|
|
42
|
+
--agent-id <id> 稳定身份(默认 employee#<主机名>#pi-proactive-daemon)
|
|
43
|
+
--task-name <n> Windows 计划任务名(默认 PiProactiveDaemon)
|
|
44
|
+
--label <label> launchd 单元标签(macOS/Linux 侧用)
|
|
45
|
+
--service-name <n> systemd 单元名(Linux 侧用)
|
|
46
|
+
--install 先跑安装器再验收
|
|
47
|
+
--check-residue 只查卸载残留(任务/进程/目录/socket)
|
|
48
|
+
--selftest 离线自检(非 Windows 上也能跑;真机项标「未验证」)
|
|
49
|
+
-h, --help 本帮助
|
|
50
|
+
`;
|
|
51
|
+
|
|
52
|
+
function parseArgs(argv) {
|
|
53
|
+
const o = {
|
|
54
|
+
agentDir: defaultAgentDir(),
|
|
55
|
+
agentId: "",
|
|
56
|
+
taskName: process.env.PI_PROACTIVE_DAEMON_TASK || "PiProactiveDaemon",
|
|
57
|
+
label: process.env.PI_PROACTIVE_DAEMON_LABEL || "com.pi-proactive.daemon",
|
|
58
|
+
serviceName: process.env.PI_PROACTIVE_DAEMON_SERVICE || "pi-proactive-daemon.service",
|
|
59
|
+
install: false,
|
|
60
|
+
checkResidue: false,
|
|
61
|
+
selftest: false,
|
|
62
|
+
};
|
|
63
|
+
for (let i = 0; i < argv.length; i++) {
|
|
64
|
+
const a = argv[i];
|
|
65
|
+
const next = () => {
|
|
66
|
+
const v = argv[++i];
|
|
67
|
+
if (v === undefined) {
|
|
68
|
+
console.error(`参数 ${a} 缺值`);
|
|
69
|
+
process.exit(2);
|
|
70
|
+
}
|
|
71
|
+
return v;
|
|
72
|
+
};
|
|
73
|
+
if (a === "--agent-dir") o.agentDir = path.resolve(next());
|
|
74
|
+
else if (a === "--agent-id") o.agentId = next();
|
|
75
|
+
else if (a === "--task-name") o.taskName = next();
|
|
76
|
+
else if (a === "--label") o.label = next();
|
|
77
|
+
else if (a === "--service-name") o.serviceName = next();
|
|
78
|
+
else if (a === "--install") o.install = true;
|
|
79
|
+
else if (a === "--check-residue") o.checkResidue = true;
|
|
80
|
+
else if (a === "--selftest") o.selftest = true;
|
|
81
|
+
else if (a === "-h" || a === "--help") {
|
|
82
|
+
console.log(USAGE);
|
|
83
|
+
process.exit(0);
|
|
84
|
+
} else {
|
|
85
|
+
console.error(`未知参数:${a}`);
|
|
86
|
+
process.exit(2);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return o;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 一行验收结论。
|
|
94
|
+
* @typedef {{ id: string; name: string; verdict: "通过" | "失败" | "未验证"; evidence: string }} Row
|
|
95
|
+
*/
|
|
96
|
+
|
|
97
|
+
/** 找到「已安装的包根」(全局 npm root;找不到就退回本仓库包根)。 */
|
|
98
|
+
function findInstalledPkg() {
|
|
99
|
+
const r = run("npm", ["root", "-g"], { timeout: 30_000 });
|
|
100
|
+
const globalRoot = (r.stdout ?? "").trim();
|
|
101
|
+
if (r.ok && globalRoot) {
|
|
102
|
+
const candidate = path.join(globalRoot, "pi-proactive");
|
|
103
|
+
if (fs.existsSync(candidate)) return { root: candidate, how: `npm root -g → ${candidate}` };
|
|
104
|
+
}
|
|
105
|
+
return { root: PKG_ROOT, how: "本仓库包根(未找到全局安装)" };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 扫描目录里的 .ts 文件(W6)。 */
|
|
109
|
+
function findTypeScriptFiles(pkgRoot) {
|
|
110
|
+
const out = [];
|
|
111
|
+
const pkg = readJsonSafe(path.join(pkgRoot, "package.json"));
|
|
112
|
+
const entries = Array.isArray(pkg?.files) ? pkg.files : [];
|
|
113
|
+
const walk = (dir, depth) => {
|
|
114
|
+
if (depth > 8) return;
|
|
115
|
+
let names = [];
|
|
116
|
+
try {
|
|
117
|
+
names = fs.readdirSync(dir);
|
|
118
|
+
} catch {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
for (const n of names) {
|
|
122
|
+
const p = path.join(dir, n);
|
|
123
|
+
let st;
|
|
124
|
+
try {
|
|
125
|
+
st = fs.statSync(p);
|
|
126
|
+
} catch {
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (st.isDirectory()) walk(p, depth + 1);
|
|
130
|
+
else if (p.endsWith(".ts")) out.push(p);
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
for (const entry of entries) {
|
|
134
|
+
const abs = path.join(pkgRoot, entry);
|
|
135
|
+
let st;
|
|
136
|
+
try {
|
|
137
|
+
st = fs.statSync(abs);
|
|
138
|
+
} catch {
|
|
139
|
+
continue; // 白名单里的目录在开发态可能不存在(build/)
|
|
140
|
+
}
|
|
141
|
+
if (st.isDirectory()) walk(abs, 0);
|
|
142
|
+
else if (abs.endsWith(".ts")) out.push(abs);
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** W5:端点路径长度(< 100B 的硬约束)。 */
|
|
148
|
+
function checkSocketPath(agentId, agentDir) {
|
|
149
|
+
const s = resolveSocketPath(PKG_ROOT, agentId, agentDir);
|
|
150
|
+
if (!s) {
|
|
151
|
+
return { id: "W5", name: "路径长度 < 100B", verdict: "失败", evidence: "算不出端点路径(socket 模块缺失)" };
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
id: "W5",
|
|
155
|
+
name: "路径长度 < 100B",
|
|
156
|
+
verdict: s.bytes <= MAX_SOCKET_PATH_BYTES ? "通过" : "失败",
|
|
157
|
+
evidence: `${s.path} = ${s.bytes}B(上限 ${MAX_SOCKET_PATH_BYTES}B)`,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** W6:包内零 .ts。 */
|
|
162
|
+
function checkZeroTypeScript(installedRoot) {
|
|
163
|
+
const ts = findTypeScriptFiles(installedRoot);
|
|
164
|
+
return {
|
|
165
|
+
id: "W6",
|
|
166
|
+
name: "包内零 .ts",
|
|
167
|
+
verdict: ts.length === 0 ? "通过" : "失败",
|
|
168
|
+
evidence:
|
|
169
|
+
ts.length === 0
|
|
170
|
+
? `按 package.json 的 files 白名单扫描 ${installedRoot}:0 个 .ts`
|
|
171
|
+
: `发现 .ts:${ts.join(", ")}`,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** W1:安装可见性。 */
|
|
176
|
+
function checkInstallVisible(installedRoot, how) {
|
|
177
|
+
const pkgJson = path.join(installedRoot, "package.json");
|
|
178
|
+
const binEntry = readJsonSafe(pkgJson)?.bin?.["pi-proactive"];
|
|
179
|
+
const binPath = binEntry ? path.join(installedRoot, binEntry.replace(/^\.\//, "")) : "";
|
|
180
|
+
const binExists = binPath ? fs.existsSync(binPath) : false;
|
|
181
|
+
if (!binExists) {
|
|
182
|
+
return {
|
|
183
|
+
id: "W1",
|
|
184
|
+
name: "安装可见性",
|
|
185
|
+
verdict: "失败",
|
|
186
|
+
evidence: `找不到可执行入口:${binPath || "package.json 无 bin.pi-proactive"}(${how})`,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
const help = run(process.execPath, [binPath, "--help"], { timeout: 30_000 });
|
|
190
|
+
return {
|
|
191
|
+
id: "W1",
|
|
192
|
+
name: "安装可见性",
|
|
193
|
+
verdict: help.ok ? "通过" : "失败",
|
|
194
|
+
evidence: `${binPath} --help → exit ${help.code}${
|
|
195
|
+
help.ok ? `;输出首行:${(help.stdout ?? "").split("\n")[0] ?? ""}` : `;stderr:${(help.stderr ?? "").slice(0, 200)}`
|
|
196
|
+
}`,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** W2:启动注入(扩展已注册)。 */
|
|
201
|
+
function checkExtensionRegistered(installedRoot) {
|
|
202
|
+
const exts = readJsonSafe(path.join(installedRoot, "package.json"))?.pi?.extensions ?? [];
|
|
203
|
+
if (!Array.isArray(exts) || exts.length === 0) {
|
|
204
|
+
return { id: "W2", name: "启动注入(扩展注册)", verdict: "失败", evidence: "package.json 的 pi.extensions 为空" };
|
|
205
|
+
}
|
|
206
|
+
const missing = exts.filter((e) => !fs.existsSync(path.join(installedRoot, e.replace(/^\.\//, ""))));
|
|
207
|
+
return {
|
|
208
|
+
id: "W2",
|
|
209
|
+
name: "启动注入(扩展注册)",
|
|
210
|
+
verdict: missing.length === 0 ? "通过" : "失败",
|
|
211
|
+
evidence:
|
|
212
|
+
missing.length === 0
|
|
213
|
+
? `pi.extensions=${exts.join(", ")} 全部存在(${installedRoot})`
|
|
214
|
+
: `缺少:${missing.join(", ")}`,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** W3:常驻(进程在 + 心跳在更新 + 计划任务已建)。 */
|
|
219
|
+
function checkResident(platform, agentDir, taskName, label, serviceName) {
|
|
220
|
+
const hbFile = path.join(agentDir, "proactive", "daemon", "heartbeat.json");
|
|
221
|
+
const hb = readJsonSafe(hbFile);
|
|
222
|
+
if (!hb || typeof hb.ts !== "number") {
|
|
223
|
+
return { id: "W3", name: "常驻(进程/心跳/任务)", verdict: "失败", evidence: `没有心跳文件:${hbFile}` };
|
|
224
|
+
}
|
|
225
|
+
const age = Date.now() - hb.ts;
|
|
226
|
+
const alive = isAlive(hb.pid);
|
|
227
|
+
let hostEvidence = "";
|
|
228
|
+
let hostOk = false;
|
|
229
|
+
if (platform === "win32") {
|
|
230
|
+
const r = run("schtasks", ["/Query", "/TN", taskName]);
|
|
231
|
+
hostOk = r.ok;
|
|
232
|
+
hostEvidence = `schtasks /Query /TN ${taskName} → exit ${r.code}`;
|
|
233
|
+
} else if (platform === "darwin") {
|
|
234
|
+
const r = run("launchctl", ["print", `gui/${process.getuid?.() ?? 0}/${label}`]);
|
|
235
|
+
hostOk = r.ok;
|
|
236
|
+
hostEvidence = `launchctl print → exit ${r.code}`;
|
|
237
|
+
} else {
|
|
238
|
+
const r = run("systemctl", ["--user", "is-active", serviceName]);
|
|
239
|
+
hostOk = r.ok;
|
|
240
|
+
hostEvidence = `systemctl --user is-active → exit ${r.code}`;
|
|
241
|
+
}
|
|
242
|
+
const fresh = age < 20_000;
|
|
243
|
+
const verdict = alive && fresh && hostOk ? "通过" : "失败";
|
|
244
|
+
return {
|
|
245
|
+
id: "W3",
|
|
246
|
+
name: "常驻(进程/心跳/任务)",
|
|
247
|
+
verdict,
|
|
248
|
+
evidence: `心跳 ${hbFile} ts 距今 ${Math.round(age / 1000)}s;pid=${hb.pid} ${alive ? "在跑" : "不在"};${hostEvidence}`,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** W4:卸载零残留(任务 / 进程 / 目录 / socket)。 */
|
|
253
|
+
function checkResidue(platform, agentDir, taskName, label, serviceName) {
|
|
254
|
+
const daemonDir = path.join(agentDir, "proactive", "daemon");
|
|
255
|
+
const residue = [];
|
|
256
|
+
if (platform === "win32") {
|
|
257
|
+
if (run("schtasks", ["/Query", "/TN", taskName]).ok) residue.push(`计划任务仍在:${taskName}`);
|
|
258
|
+
} else if (platform === "darwin") {
|
|
259
|
+
if (run("launchctl", ["print", `gui/${process.getuid?.() ?? 0}/${label}`]).ok) residue.push(`服务仍加载:${label}`);
|
|
260
|
+
} else {
|
|
261
|
+
if (run("systemctl", ["--user", "is-enabled", serviceName]).ok) residue.push(`服务仍 enabled:${serviceName}`);
|
|
262
|
+
}
|
|
263
|
+
if (fs.existsSync(daemonDir)) residue.push(`状态/日志目录仍在:${daemonDir}`);
|
|
264
|
+
const socketPath = resolveSocketPath(PKG_ROOT, defaultAgentId(), agentDir)?.path;
|
|
265
|
+
if (socketPath && fs.existsSync(socketPath)) residue.push(`socket 端点仍在:${socketPath}`);
|
|
266
|
+
const hb = readJsonSafe(path.join(daemonDir, "heartbeat.json"));
|
|
267
|
+
if (hb && isAlive(hb.pid)) residue.push(`值班进程还在(pid=${hb.pid})`);
|
|
268
|
+
return {
|
|
269
|
+
id: "W4",
|
|
270
|
+
name: "卸载零残留",
|
|
271
|
+
verdict: residue.length === 0 ? "通过" : "失败",
|
|
272
|
+
evidence: residue.length === 0 ? "任务/进程/目录/socket 四处均无残留" : residue.join(";"),
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** 打印证据表。 */
|
|
277
|
+
function printTable(rows) {
|
|
278
|
+
const width = Math.max(...rows.map((r) => r.id.length), 3);
|
|
279
|
+
console.log("| 判据 | 项目 | 结论 | 证据 |");
|
|
280
|
+
console.log(`|------|------|------|------|`);
|
|
281
|
+
for (const r of rows) {
|
|
282
|
+
console.log(`| ${r.id.padEnd(width)} | ${r.name} | ${r.verdict} | ${r.evidence.replace(/\|/g, "/")} |`);
|
|
283
|
+
}
|
|
284
|
+
const failed = rows.filter((r) => r.verdict === "失败");
|
|
285
|
+
const unverified = rows.filter((r) => r.verdict === "未验证");
|
|
286
|
+
console.log("");
|
|
287
|
+
console.log(`结论:失败 ${failed.length} 项 / 未验证 ${unverified.length} 项 / 共 ${rows.length} 项`);
|
|
288
|
+
if (unverified.length > 0) {
|
|
289
|
+
console.log(`未验证项:${unverified.map((r) => r.id).join(", ")} —— 不得当作已通过`);
|
|
290
|
+
}
|
|
291
|
+
return failed.length > 0 ? 1 : 0;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ------------------------------------------------------------------ 入口 ----
|
|
295
|
+
|
|
296
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
297
|
+
const platform = detectPlatform("");
|
|
298
|
+
const agentId = opts.agentId || defaultAgentId();
|
|
299
|
+
const installed = findInstalledPkg();
|
|
300
|
+
|
|
301
|
+
if (opts.install) {
|
|
302
|
+
const installer = path.join(PKG_ROOT, "tools", "daemon-install.mjs");
|
|
303
|
+
const r = run(process.execPath, [installer, "--agent-dir", opts.agentDir, "--agent-id", agentId], {
|
|
304
|
+
timeout: 180_000,
|
|
305
|
+
});
|
|
306
|
+
console.log(r.stdout);
|
|
307
|
+
if (!r.ok) {
|
|
308
|
+
console.error(r.stderr);
|
|
309
|
+
process.exit(1);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (opts.checkResidue) {
|
|
314
|
+
const code = printTable([checkResidue(platform, opts.agentDir, opts.taskName, opts.label, opts.serviceName)]);
|
|
315
|
+
process.exit(code);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (opts.selftest || platform !== "win32") {
|
|
319
|
+
// 离线自检:能离线判的判;**真机项一律「未验证」**(不得谎报通过)。
|
|
320
|
+
const rows = [
|
|
321
|
+
checkInstallVisible(installed.root, installed.how),
|
|
322
|
+
checkExtensionRegistered(installed.root),
|
|
323
|
+
{
|
|
324
|
+
id: "W3",
|
|
325
|
+
name: "常驻(进程/心跳/任务)",
|
|
326
|
+
verdict: "未验证",
|
|
327
|
+
evidence: `非 Windows 平台(${platform})→ 常驻/计划任务的真机结论必须在 Windows 上取证`,
|
|
328
|
+
},
|
|
329
|
+
{
|
|
330
|
+
id: "W4",
|
|
331
|
+
name: "卸载零残留",
|
|
332
|
+
verdict: "未验证",
|
|
333
|
+
evidence: "非 Windows 平台 → 卸载残留的真机结论必须在 Windows 上取证",
|
|
334
|
+
},
|
|
335
|
+
checkSocketPath(agentId, opts.agentDir),
|
|
336
|
+
checkZeroTypeScript(installed.root),
|
|
337
|
+
];
|
|
338
|
+
if (platform !== "win32") {
|
|
339
|
+
rows[0] = {
|
|
340
|
+
...rows[0],
|
|
341
|
+
verdict: rows[0].verdict === "通过" ? "通过" : rows[0].verdict,
|
|
342
|
+
evidence: `${rows[0].evidence}(本地包结构;真机全局安装仍需在 Windows 上取证)`,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
console.log(`平台:${platform}(真机验收必须在 Windows 上跑;当前是离线自检)`);
|
|
346
|
+
const code = printTable(rows);
|
|
347
|
+
process.exit(code);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const rows = [
|
|
351
|
+
checkInstallVisible(installed.root, installed.how),
|
|
352
|
+
checkExtensionRegistered(installed.root),
|
|
353
|
+
checkResident(platform, opts.agentDir, opts.taskName, opts.label, opts.serviceName),
|
|
354
|
+
checkResidue(platform, opts.agentDir, opts.taskName, opts.label, opts.serviceName),
|
|
355
|
+
checkSocketPath(agentId, opts.agentDir),
|
|
356
|
+
checkZeroTypeScript(installed.root),
|
|
357
|
+
];
|
|
358
|
+
process.exit(printTable(rows));
|