ghprobe 0.1.5 → 0.1.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 CHANGED
@@ -5,6 +5,38 @@
5
5
  格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),
6
6
  版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
7
7
 
8
+ > **版本号说明**:npm 发布采用 `0.1.x` 版本线(`npm i -g ghprobe` 装到的就是它);
9
+ > 0.1.5 及之前的 npm 包内含仓库内部 0.2~0.6 的全部能力(对应关系:npm 0.1.5 ≈ 内部 0.6.0)。
10
+ > **自 0.1.6 起条目直接使用 npm 版本号**,内部版本号不再单独记录。
11
+
12
+ ## [0.1.6] - 2026-11-13
13
+
14
+ ### Added
15
+
16
+ - **`ghprobe off` / `ghprobe on`:一键暂停与恢复(不卸载)**。off = 剥掉 hosts 块 +
17
+ 暂停计划任务 + 落暂停标记,daemon / 健康检查见到标记直接跳过(连探测请求都不发);
18
+ on = 反向恢复并当场触发一轮健康检查,约 1 分钟内重新写好 hosts。
19
+ 适合出差、换网、临时装了代理工具的场景,不用整个 uninstall。
20
+ 手动 `probe --apply` 是显式意图不受门约束,但会提示当前处于暂停状态。
21
+ - **`ghprobe report`:报障诊断包**。版本 / 系统 / 暂停状态 / status / 配置差异 /
22
+ hosts 块 / 自愈日志尾部一次收集,自动复制到剪贴板并落盘
23
+ (`%LOCALAPPDATA%\ghprobe
24
+ - **更新检查**:交互命令(install / status / doctor / probe / report)每天最多查一次
25
+ npm registry(npmmirror 优先、官方源兜底、5s 超时、失败静默),有新版提示
26
+ `npm i -g ghprobe`。只提示不自动升级;daemon / 计划任务静默路径绝不联网查版本。
27
+
28
+ ### Fixed
29
+
30
+ - **自愈日志无限膨胀**(重要):daemon / 监督者每 15~60s 追加一行,日志此前只增不减,
31
+ 长跑机器实测可攒到几十 MB。现在追加前自动滚动:超过 2MB 只保留最后 256KB,
32
+ 老用户升级后下次写入自动截断,无需手动清理。
33
+
34
+ ### Changed
35
+
36
+ - `status` 在暂停状态下首行显示恢复指引;hosts 块被 off 剥掉时不再误导用户「先跑 install」。
37
+ - `uninstall` 顺带清掉暂停标记(重装 = 全新开始)。
38
+
39
+
8
40
  ## [0.6.0] - 2026-10-31
9
41
 
10
42
  ### Added
package/README.md CHANGED
@@ -22,6 +22,7 @@
22
22
  ```bash
23
23
  npm install -g ghprobe
24
24
  ghprobe install # 探测一次 + 写入 hosts + 注册每分钟健康检查(全程只弹一次 UAC)
25
+ ghprobe off # 暂停:剥掉 hosts 块 + 停止自愈写入(不卸载;恢复用 ghprobe on)
25
26
  ghprobe uninstall # 停止:删除计划任务并还原 hosts
26
27
  ```
27
28
 
@@ -360,6 +361,15 @@ ghprobe health --apply # 检测到异常时写入系统 hosts(会弹 UAC
360
361
  # GitHub 连不上不知道哪坏了?先跑它。有 fail 项时退出码非零,方便脚本判断
361
362
  ghprobe doctor
362
363
 
364
+ # 暂停 / 恢复(不卸载):off = 剥掉 hosts 块 + 暂停计划任务 + 停止自愈写入,
365
+ # 出差、换网、临时不想用时不用整个卸载;on = 恢复并当场触发一轮健康检查
366
+ ghprobe off
367
+ ghprobe on
368
+
369
+ # 报障诊断包:版本 / status / 配置差异 / hosts 块 / 日志尾部,
370
+ # 自动复制到剪贴板并落盘 —— 提 issue 时直接粘贴,报障成本最低
371
+ ghprobe report
372
+
363
373
  # 从 GitHub 官方 api.github.com/meta 更新候选池缓存(每天一次即可)
364
374
  # 该 API 未认证限 60 次/小时,所以别频繁调;health 到点也会自动调,一般不用手动
365
375
  ghprobe update-pool
@@ -469,16 +479,19 @@ schedule:
469
479
  ```
470
480
 
471
481
 
472
- ### 卸载 / 回滚与日志
482
+ ### 卸载 / 暂停 / 回滚与日志
473
483
 
474
484
  ```bash
485
+ ghprobe off # 暂停:剥掉 hosts 块 + 暂停计划任务 + 停止自愈(不卸载)
486
+ ghprobe on # 恢复:清暂停标记 + 恢复计划任务 + 当场触发一轮健康检查
475
487
  ghprobe uninstall # 删除计划任务 + 还原 hosts(只删自己的行)
476
488
  ```
477
489
 
478
490
  如果你是用 `install.ps1` 部署的,也可以用 `.\install.ps1 -Uninstall`。
479
491
 
492
+ - **只是临时不想用**(出差、换网络环境、装了别的代理工具):用 `ghprobe off`,不用卸载 —— GitHub 立刻回到系统默认解析,自愈完全停手;`ghprobe on` 一键恢复(约 1 分钟内重新写好 hosts)。
480
493
  - **手动回滚 hosts**:写入前会备份到 `C:\Windows\System32\drivers\etc\hosts.ghprobe-bak`;但更推荐用 `ghprobe uninstall`(它按块剥离,不会误伤第三方后来追加的条目)。
481
- - **健康检查日志**:`%LOCALAPPDATA%\ghprobe\ghprobe.log`。计划任务静默运行、stdout/stderr 都被丢弃,所以每轮结果会追加到这里(含 `action` / `reason` / `changed`),跑挂了能查。
494
+ - **健康检查日志**:`%LOCALAPPDATA%\ghprobe\ghprobe.log`。计划任务静默运行、stdout/stderr 都被丢弃,所以每轮结果会追加到这里(含 `action` / `reason` / `changed`),跑挂了能查。日志**自动滚动**:超过 2MB 只保留最后 256KB,不会无限膨胀(0.1.6 起;此前长跑机器能攒出几十 MB,升级后下次写入自动截断)。
482
495
  - **用户配置**:`%LOCALAPPDATA%\ghprobe\config.yaml`(可编辑,见上一节)。
483
496
  - **候选池缓存**:`%LOCALAPPDATA%\ghprobe\pool.cache.json`(`update-pool` 自动维护,不用手改)。
484
497
  - **部署日志**:用 `install.ps1` 时默认写到 `%TEMP%\ghprobe-deploy.log`。
@@ -559,6 +572,12 @@ ghprobe install -c my.yaml
559
572
  **Q:为什么有时候修不好?**
560
573
  工具只能找到**当前网络、当前时刻**可用的 IP。如果遇到的是 SNI 主机名黑名单(换 IP 修不了)或 IP 黑洞,可能所有候选都不可用。此时会命中兜底链(如果配了),或者触发全灭护栏 —— **保留旧 IP、不写空块**,绝不会硬塞一个错误 IP 进去。
561
574
 
575
+ **Q:点了 GitHub 一开始没反应,过一会又自己好了?**
576
+ 这是**链路抖动 + 客户端自动兜底**在工作,不是坏了。ghprobe 每个域名写 2 条 IP(主 + 备选):首条恰好在分钟级的抖动窗口里被拦时,浏览器 / git 会自动去试备选(代价是多等一个连接超时,所以「没反应」一下);两条同时失效的话,常驻自愈 15~45s 内换 IP。「没反应」持续超过一分钟才需要管:跑 `ghprobe status` 看 hosts 与自愈日志,不行再 `ghprobe doctor` 定位。
577
+
578
+ **Q:遇到问题怎么报?**
579
+ 先跑 `ghprobe report`(生成诊断包并复制到剪贴板)和 `ghprobe doctor`,把两份输出贴到 <https://github.com/zmw-zmw/ghprobe/issues>。诊断包只含版本信息、hosts 块和自愈日志尾部,**不含任何个人数据**。
580
+
562
581
  **Q:会不会影响别的软件?**
563
582
  不会。它**只写 hosts 文件**,不装代理、不改系统代理设置、不劫持流量。而且只操作 `# === ghprobe begin/end ===` 之间属于它自己的那一块,**你原有的 hosts 条目和第三方工具写的条目都不会被改动**;卸载时也只剥掉自己的块。
564
583
 
package/bin/ghprobe.js CHANGED
@@ -13,9 +13,10 @@ import { hasLocalNetwork } from "../src/net.js";
13
13
  import { probeNow, startServer } from "../src/server.js";
14
14
  import { runHealth, blockFromProbeResult, shouldKeepOldBlock, parseBlock } from "../src/health.js";
15
15
  import { runDoctor, formatReport, queryTask } from "../src/doctor.js";
16
- import { runStatus } from "../src/status.js";
16
+ import { runStatus, readLogTail } from "../src/status.js";
17
17
  import { runDaemon, readHeartbeat, heartbeatFresh, daemonRunning } from "../src/daemon.js";
18
- import { mergeHosts, applyHosts, restoreHosts, WIN_HOSTS, acquireHostsLock, releaseHostsLock, runElevated, buildUninstallTaskScript, buildInstallScript, buildHiddenRunnerVbs, userDataDir } from "../src/hosts.js";
18
+ import { mergeHosts, applyHosts, restoreHosts, WIN_HOSTS, acquireHostsLock, releaseHostsLock, runElevated, buildUninstallTaskScript, buildInstallScript, buildHiddenRunnerVbs, userDataDir, isPaused, setPaused, buildPauseTaskScript, buildStartTaskScript, rotateLogIfNeeded } from "../src/hosts.js";
19
+ import { maybeNotifyUpdate } from "../src/updates.js";
19
20
  import { readFileSync } from "node:fs";
20
21
  import { spawn } from "node:child_process";
21
22
 
@@ -47,6 +48,8 @@ function appendRawLog(line) {
47
48
  try {
48
49
  const f = healthLogPath();
49
50
  fs.mkdirSync(path.dirname(f), { recursive: true });
51
+ // 追加前先滚动:日志只增不减的话,长跑机器几个月能攒出几十 MB(0.1.6 前实测 33MB)
52
+ rotateLogIfNeeded(f);
50
53
  const prefix = fs.existsSync(f) ? "" : "\ufeff";
51
54
  fs.appendFileSync(f, prefix + line);
52
55
  } catch { /* 日志写失败不影响主流程 */ }
@@ -83,6 +86,11 @@ const program = new Command();
83
86
  const PKG = JSON.parse(readFileSync(path.join(ROOT, "package.json"), "utf8"));
84
87
  program.name("ghprobe").description("GitHub 直连优选:本地 DoH + TLS 证书终审探测 + hosts 生成(无代理)").version(PKG.version);
85
88
 
89
+ // 交互命令开头的版本提示(内部每天最多查一次 registry;只提示不自动升级,失败静默)
90
+ function notifyUpdate() {
91
+ return maybeNotifyUpdate(PKG.version, console.log);
92
+ }
93
+
86
94
  // 打印一轮结果的可读摘要
87
95
  function printSummary(r) {
88
96
  for (const [domain, rs] of r.ranked) {
@@ -99,6 +107,7 @@ program.command("doctor")
99
107
  .description("一键自检:网络 / DoH / 探测 / hosts / 计划任务,人话结论 + 下一步建议")
100
108
  .option("-c, --config <path>", "用户配置覆盖")
101
109
  .action(async (opts) => {
110
+ await notifyUpdate();
102
111
  const cfg = loadConfig(opts.config);
103
112
  console.error("ghprobe 自检中(跑一轮完整探测,约 1 分钟)…");
104
113
  const r = await runDoctor(cfg, (m) => console.error(m));
@@ -119,6 +128,7 @@ program.command("status")
119
128
  .description("秒级速查:hosts 现状 + 自愈日志尾部 + 计划任务(不打任何网络请求)")
120
129
  .option("-c, --config <path>", "用户配置覆盖")
121
130
  .action(async (opts) => {
131
+ await notifyUpdate();
122
132
  const cfg = loadConfig(opts.config);
123
133
  const r = await runStatus(cfg, { taskQuery: () => queryTask() });
124
134
  console.log(r.lines.join("\n"));
@@ -140,10 +150,15 @@ program.command("probe")
140
150
  if (opts.json) {
141
151
  const out = { at: r.at, domains: [...r.ranked].map(([d, rs]) => ({ domain: d, alive: rs.map((x) => ({ ip: x.ip, tcpMs: x.tcpMs, tlsMs: x.tlsMs, ttfb: x.ttfb, score: x.score })) })), extra: [...r.extra].map(([d, rs]) => ({ domain: d, alive: rs.map((x) => x.ip) })) };
142
152
  console.log(JSON.stringify(out, null, 2));
143
- } else {
144
- printSummary(r);
153
+ return; // JSON 是给脚本消费的,不掺提示行
145
154
  }
155
+ await notifyUpdate();
156
+ printSummary(r);
146
157
  if (opts.apply) {
158
+ // 显式命令不受暂停门约束(那是给自动化设的),但要提醒用户自动化还停着
159
+ if (isPaused()) {
160
+ console.error("注意:当前处于暂停状态(ghprobe off),本轮按你的显式要求仍写入 hosts;恢复自动化跑 ghprobe on");
161
+ }
147
162
  const lock = acquireHostsLock();
148
163
  if (!lock) {
149
164
  console.error("另一进程正在写 hosts,本轮跳过(下轮健康检查会自动补写)");
@@ -188,6 +203,12 @@ program.command("health")
188
203
  .option("-a, --apply", "检测到异常时写入系统 hosts(会弹 UAC)")
189
204
  .option("-f, --force", "跳过 daemon 监督逻辑,本轮亲自跑(诊断用)")
190
205
  .action(async (opts) => {
206
+ // ghprobe off 后自愈彻底停手:不探测、不写 hosts、不拉 daemon(标记由 off/on 维护)
207
+ if (isPaused()) {
208
+ console.error("ghprobe 处于暂停状态(ghprobe off),本轮跳过 —— 恢复跑 ghprobe on");
209
+ writeHealthLog("skip", "已暂停(ghprobe off),本轮不探测不写 hosts");
210
+ return;
211
+ }
191
212
  let cfg = loadConfig(opts.config);
192
213
  // 0.5.0 监督者逻辑:daemon 心跳新鲜 -> 秒退(它在干活,重复探测纯属浪费);
193
214
  // 心跳陈旧/没有 -> 拉起新 daemon(分离进程),本轮仍照常执行,作为 daemon 就位前的兜底。
@@ -231,6 +252,7 @@ program.command("install")
231
252
  .option("-c, --config <path>", "用户配置覆盖(间隔取 schedule.healthMin 分钟)")
232
253
  .option("--skip-probe", "只注册计划任务,不立即探测写 hosts(等下一次定时触发)")
233
254
  .action(async (opts) => {
255
+ await notifyUpdate();
234
256
  // 生成用户可编辑的配置文件(已存在则不覆盖,保留用户改动)
235
257
  const ucfg = ensureUserConfig();
236
258
  const cfg = loadConfig(opts.config);
@@ -297,9 +319,55 @@ program.command("install")
297
319
  console.log("停止:ghprobe uninstall");
298
320
  });
299
321
 
322
+ program.command("off")
323
+ .description("一键暂停:剥掉 hosts 块 + 暂停计划任务 + 落暂停标记(自愈停止写入)。GitHub 回到系统默认解析;恢复用 ghprobe on")
324
+ .action(async () => {
325
+ // 先落标记:off 与计划任务正在跑的一轮是并发的,标记是第一动作才能保证
326
+ // 正在跑的那轮也看到暂停,不会刚剥完块又被那一轮写回去
327
+ setPaused(true);
328
+ let taskOk = true;
329
+ try {
330
+ const r = await runElevated(buildPauseTaskScript(true));
331
+ if (r.code !== 0) { taskOk = false; console.error(`暂停计划任务失败: ${r.err || r.out}`); }
332
+ } catch (e) { taskOk = false; console.error(`暂停计划任务失败: ${e.message}`); }
333
+ try {
334
+ await restoreHosts();
335
+ console.log("已暂停:hosts 块已剥离、DNS 缓存已刷新,GitHub 恢复系统默认解析");
336
+ } catch (e) {
337
+ console.error(`剥离 hosts 块失败: ${e.message}(暂停标记已落,自动化不会再写入;hosts 里可能还残留 ghprobe 条目,可手动删或重跑 ghprobe off)`);
338
+ process.exitCode = 1;
339
+ }
340
+ if (taskOk) { console.log("计划任务已暂停,常驻自愈停止写入"); }
341
+ console.log("恢复:ghprobe on(当场触发一轮健康检查,约 1 分钟内重新写好 hosts)");
342
+ if (!taskOk) { process.exitCode = 1; }
343
+ });
344
+
345
+ program.command("on")
346
+ .description("恢复(ghprobe off 的逆操作):清暂停标记 + 恢复计划任务 + 当场触发一轮健康检查")
347
+ .action(async () => {
348
+ setPaused(false);
349
+ let ok = true;
350
+ try {
351
+ const r = await runElevated(buildPauseTaskScript(false));
352
+ if (r.code !== 0) { ok = false; console.error(`恢复计划任务失败: ${r.err || r.out}`); }
353
+ } catch (e) { ok = false; console.error(`恢复计划任务失败: ${e.message}`); }
354
+ if (ok) {
355
+ try {
356
+ const r = await runElevated(buildStartTaskScript());
357
+ if (r.code !== 0) { console.error(`当场触发失败: ${r.err || r.out}(不致命,下一个间隔任务会自动跑)`); }
358
+ else { console.log("已当场触发一轮健康检查,约 1 分钟内 hosts 重新写好"); }
359
+ } catch (e) { console.error(`当场触发失败: ${e.message}(不致命,下一个间隔任务会自动跑)`); }
360
+ } else {
361
+ process.exitCode = 1;
362
+ }
363
+ console.log("已恢复:ghprobe 自动自愈回到工作状态");
364
+ });
365
+
300
366
  program.command("uninstall")
301
367
  .description("删除 ghprobe 计划任务并还原 hosts")
302
368
  .action(async () => {
369
+ // 暂停标记一并清掉:重装 = 全新开始,不要把上一次的 off 状态带进新装
370
+ setPaused(false);
303
371
  // 两步互相独立:任一步失败都不影响另一步,最后统一置 exitCode
304
372
  try {
305
373
  await restoreHosts();
@@ -370,4 +438,68 @@ program.command("config-diff")
370
438
  console.log(`配置文件:${userConfigPath()}`);
371
439
  });
372
440
 
441
+ program.command("report")
442
+ .description("生成报障诊断包:版本/系统/暂停状态/status/配置差异/hosts 块/日志尾部,复制到剪贴板并落盘(贴 issue 用)")
443
+ .option("-o, --out <path>", "诊断包写入路径(默认 %LOCALAPPDATA%\\ghprobe\\report-<时间>.txt)")
444
+ .action(async (opts) => {
445
+ await notifyUpdate();
446
+ const cfg = loadConfig();
447
+ const lines = [];
448
+ lines.push(`ghprobe ${PKG.version} report`);
449
+ lines.push(`node ${process.version} / ${process.platform} / os=${os.release()} / ${new Date().toISOString()}`);
450
+ lines.push(`暂停状态:${isPaused() ? "已暂停(ghprobe off)" : "正常"}`);
451
+ lines.push("");
452
+ lines.push("== status ==");
453
+ try {
454
+ const r = await runStatus(cfg, { taskQuery: () => queryTask() });
455
+ lines.push(...r.lines);
456
+ } catch (e) { lines.push(`status 失败: ${e.message}`); }
457
+ lines.push("");
458
+ lines.push("== config-diff ==");
459
+ try {
460
+ const { hasUser, diff } = configDiff();
461
+ lines.push(hasUser ? (diff.length ? `用户配置与默认有 ${diff.length} 处差异:${diff.map((d) => d.key).join(", ")}` : "用户配置与当前默认一致") : "无用户配置(还没跑过 install)");
462
+ } catch (e) { lines.push(`config-diff 失败: ${e.message}`); }
463
+ lines.push("");
464
+ lines.push("== hosts 块 ==");
465
+ try {
466
+ const cur = readFileSync(WIN_HOSTS, "utf8");
467
+ const m = cur.match(/# === ghprobe begin ===[\s\S]*?(?:# === ghprobe end ===|$)/);
468
+ lines.push(m ? m[0].trimEnd() : "(无 ghprobe 块)");
469
+ } catch (e) { lines.push(`hosts 读取失败: ${e.message}`); }
470
+ lines.push("");
471
+ lines.push("== 自愈日志尾部(最近 50 行) ==");
472
+ const tail = readLogTail(50);
473
+ if (tail.length) { lines.push(...tail); } else { lines.push("(暂无日志)"); }
474
+ lines.push("");
475
+ lines.push("== 下一步 ==");
476
+ lines.push("再跑一次 `ghprobe doctor`,把它的输出也贴进 issue(约 1 分钟,report 里放不下探测过程)。");
477
+ const text = lines.join("\r\n") + "\r\n";
478
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
479
+ const out = opts.out || path.join(userDataDir(), `report-${stamp}.txt`);
480
+ try {
481
+ fs.mkdirSync(path.dirname(out), { recursive: true });
482
+ fs.writeFileSync(out, text, "utf8");
483
+ } catch (e) {
484
+ console.error(`诊断包落盘失败: ${e.message}`);
485
+ process.exitCode = 1;
486
+ }
487
+ // 尝试复制到剪贴板:失败不影响落盘(诊断包已在文件里)
488
+ let copied = false;
489
+ try {
490
+ await new Promise((resolve) => {
491
+ const child = spawn("clip", [], { stdio: ["pipe", "ignore", "ignore"] });
492
+ child.on("error", () => resolve());
493
+ child.on("close", () => { copied = true; resolve(); });
494
+ child.stdin.on("error", () => { /* clip 不存在时 write 会 emit,别让它炸进程 */ });
495
+ child.stdin.write(text);
496
+ child.stdin.end();
497
+ setTimeout(resolve, 3000); // clip 偶发不回 close,兜底别挂着
498
+ });
499
+ } catch { /* 复制失败不影响落盘 */ }
500
+ console.log(copied
501
+ ? `诊断包已复制到剪贴板(可直接粘贴进 issue),同时写在 ${out}`
502
+ : `诊断包已写入 ${out}(剪贴板复制不可用,请手动打开复制)`);
503
+ });
504
+
373
505
  program.parseAsync();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ghprobe",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "GitHub direct-connect optimizer: local DoH resolution + TLS-verified probing + hosts generation (no proxy)",
5
5
  "keywords": [
6
6
  "github",
package/src/daemon.js CHANGED
@@ -12,7 +12,7 @@
12
12
  // · daemon 崩溃 -> 下一分钟监督者自动复活它;旧版本 npm 用户没有心跳文件,行为完全不变
13
13
  import fs from "node:fs";
14
14
  import path from "node:path";
15
- import { userDataDir, applyHosts, mergeHosts, WIN_HOSTS } from "./hosts.js";
15
+ import { userDataDir, applyHosts, mergeHosts, WIN_HOSTS, isPaused } from "./hosts.js";
16
16
  import { hasLocalNetwork } from "./net.js";
17
17
  import { loadConfig } from "./config.js";
18
18
  import { parseBlock, runHealth, shouldKeepOldBlock } from "./health.js";
@@ -72,6 +72,12 @@ export function nextIntervalMs(result, intervals = {}) {
72
72
  // deps.gate(0.6.0):用户行为门控 —— 非紧急写入在「用户正用浏览器」时被推迟,
73
73
  // 攒进门控的 pendingBlock;健康轮结束后由 flushPending 空闲落地。
74
74
  export async function daemonCycle(cfg, log, deps = {}) {
75
+ // ghprobe off 后:不探测、不写 hosts。暂停检查必须放在最前 —— 连「断网跳过」都
76
+ // 不能先判,off 语义是「先别动我的网络」,探测请求本身也不该发。
77
+ // deps.isPaused 可注入(单测不碰真实用户目录)。
78
+ if ((deps.isPaused || isPaused)()) {
79
+ return { action: "skip", reason: "已暂停(ghprobe off 恢复)", offline: false, block: null, stuck: [] };
80
+ }
75
81
  if (!(await (deps.hasNetwork || hasLocalNetwork)(cfg))) {
76
82
  return { action: "skip", reason: "本地无网络连接", offline: true, block: null, stuck: [] };
77
83
  }
package/src/hosts.js CHANGED
@@ -17,6 +17,63 @@ export function userDataDir() {
17
17
  return path.join(base, "ghprobe");
18
18
  }
19
19
 
20
+ // ---------- 暂停(ghprobe off / on,0.1.6) ----------
21
+ // off 的语义是「先别动我的网络」,不是卸载:剥掉 hosts 块 + 暂停计划任务 + 落暂停标记。
22
+ // daemon / health 见到标记直接跳过,不再探测也不写 hosts;on 反之并当场触发一轮。
23
+ // 标记文件必须是第一动作 —— off 和计划任务正在跑的一轮是并发的,先落标记才能保证
24
+ // 「正在跑的那轮也看到暂停」,不会出现 off 刚剥完块又被那一轮写回去。
25
+
26
+ // 暂停标记路径(dir 可注入供单测,不碰真实用户目录)
27
+ export function pausedFlagPath(dir = userDataDir()) {
28
+ return path.join(dir, "paused");
29
+ }
30
+
31
+ export function isPaused(file = pausedFlagPath()) {
32
+ try { return fs.existsSync(file); } catch { return false; }
33
+ }
34
+
35
+ export function setPaused(paused, file = pausedFlagPath()) {
36
+ try {
37
+ if (paused) {
38
+ fs.mkdirSync(path.dirname(file), { recursive: true });
39
+ fs.writeFileSync(file, `${new Date().toISOString()}\n`);
40
+ } else {
41
+ fs.rmSync(file, { force: true });
42
+ }
43
+ return true;
44
+ } catch { return false; }
45
+ }
46
+
47
+ // ---------- 日志滚动(0.1.6) ----------
48
+ // daemon/监督者每 15~60s 追加一行日志,此前只增不减 —— 长跑机器会无限膨胀
49
+ // (2026-11-13 实测一台装了几天没动的机器已经 33MB)。滚动策略:超过 max 字节就把
50
+ // 文件截成「保留最后 keep 字节」,控制权交还给追加方,不需要额外的滚动进程。
51
+ export const LOG_MAX_BYTES = 2 * 1024 * 1024; // 超过就滚
52
+ export const LOG_KEEP_BYTES = 256 * 1024; // 滚动时保留的尾部
53
+
54
+ export function rotateLogIfNeeded(file, opts = {}) {
55
+ const max = opts.max ?? LOG_MAX_BYTES;
56
+ const keep = opts.keep ?? LOG_KEEP_BYTES;
57
+ let st;
58
+ try { st = fs.statSync(file); } catch { return false; }
59
+ if (st.size <= max) { return false; }
60
+ try {
61
+ const start = Math.max(0, st.size - keep);
62
+ const fh = fs.openSync(file, "r");
63
+ let buf;
64
+ try {
65
+ buf = Buffer.alloc(st.size - start);
66
+ fs.readSync(fh, buf, 0, buf.length, start);
67
+ } finally { fs.closeSync(fh); }
68
+ // 起点大概率落在半行上,掐掉第一行残片,避免留下一条读不懂的脏日志
69
+ const text = buf.toString("utf8").replace(/^[^\r\n]*\r?\n/, "");
70
+ const tmp = `${file}.rotating`;
71
+ fs.writeFileSync(tmp, "\ufeff" + text, "utf8");
72
+ fs.renameSync(tmp, file);
73
+ return true;
74
+ } catch { return false; } // 滚动失败不影响主流程:下一轮追加时再试
75
+ }
76
+
20
77
  // 写 hosts 跨进程锁:probe --apply 与计划任务(health --apply)可能并发,避免读改写互踩
21
78
  export const HOSTS_LOCK = path.join(os.tmpdir(), "ghprobe-hosts.lock");
22
79
  const LOCK_STALE_MS = 60000;
@@ -118,6 +175,32 @@ export function buildUninstallTaskScript(taskName = "ghprobe") {
118
175
  + ` exit 0`;
119
176
  }
120
177
 
178
+ // 构造「暂停/恢复计划任务」的提权脚本(ghprobe off / on 用)。任务不存在直接 exit 0(幂等):
179
+ // off 在没安装过的机器上跑不应报错;重复 off/on 同样幂等。
180
+ // 与 buildUninstallTaskScript 同一条纪律:动作必须 -ErrorAction Stop,否则「拒绝访问」
181
+ // 这类非终止错误会把失败伪装成 exit 0。
182
+ export function buildPauseTaskScript(paused, taskName = "ghprobe") {
183
+ const verb = paused ? "Disable" : "Enable";
184
+ const cn = paused ? "暂停" : "恢复";
185
+ return `try { [Console]::OutputEncoding = [Text.Encoding]::UTF8 } catch {};`
186
+ + ` if (-not (Get-ScheduledTask -TaskName '${taskName}' -ErrorAction SilentlyContinue))`
187
+ + ` { [Console]::Error.WriteLine('计划任务 ${taskName} 不存在,跳过'); exit 0 };`
188
+ + ` try { ${verb}-ScheduledTask -TaskName '${taskName}' -ErrorAction Stop | Out-Null }`
189
+ + ` catch { [Console]::Error.WriteLine('${cn}计划任务失败: ' + $_.Exception.Message); exit 1 };`
190
+ + ` exit 0`;
191
+ }
192
+
193
+ // 构造「立即触发一轮健康检查」的提权脚本(ghprobe on 用):恢复后不等下一个间隔,
194
+ // 当场跑一轮 —— on 之后用户应该在一分钟内看到 hosts 重新写好,而不是干等。
195
+ export function buildStartTaskScript(taskName = "ghprobe") {
196
+ return `try { [Console]::OutputEncoding = [Text.Encoding]::UTF8 } catch {};`
197
+ + ` if (-not (Get-ScheduledTask -TaskName '${taskName}' -ErrorAction SilentlyContinue))`
198
+ + ` { [Console]::Error.WriteLine('计划任务 ${taskName} 不存在,跳过'); exit 0 };`
199
+ + ` try { Start-ScheduledTask -TaskName '${taskName}' -ErrorAction Stop }`
200
+ + ` catch { [Console]::Error.WriteLine('触发计划任务失败: ' + $_.Exception.Message); exit 1 };`
201
+ + ` exit 0`;
202
+ }
203
+
121
204
  // 构造「写入 hosts + 注册计划任务」的提权脚本(install 用):一次 UAC 完成两件事。
122
205
  // 与 buildApplyScript 的区别:中途不 exit,两件事都做完后再按综合结果返回退出码,
123
206
  // 这样 install 只需弹一次 UAC,且能拿到「两件事各自成功与否」的确定结果。
package/src/status.js CHANGED
@@ -6,7 +6,7 @@
6
6
  import fs from "node:fs";
7
7
  import path from "node:path";
8
8
  import { parseBlock, targetDomains } from "./health.js";
9
- import { userDataDir, WIN_HOSTS } from "./hosts.js";
9
+ import { userDataDir, WIN_HOSTS, isPaused } from "./hosts.js";
10
10
  import { readHeartbeat, heartbeatFresh, processAlive } from "./daemon.js";
11
11
 
12
12
  export function healthLogPath() {
@@ -28,15 +28,24 @@ function generatedAt(content) {
28
28
  }
29
29
 
30
30
  // 纯函数:由既有状态组装速查报告。全离线 —— 不探测、不解析、不查任务网络。
31
- export function buildStatusReport({ hostsContent = "", cfg, logTail = [], task = null, daemon = undefined }) {
31
+ export function buildStatusReport({ hostsContent = "", cfg, logTail = [], task = null, daemon = undefined, paused = false }) {
32
32
  const current = parseBlock(hostsContent);
33
33
  const targets = targetDomains(cfg);
34
34
  const lines = [];
35
35
 
36
+ // ⓪ 暂停状态优先展示:off 会剥掉 hosts 块,如果不提示,下面「还没有 ghprobe 块」
37
+ // 的文案会误导用户以为没装好
38
+ if (paused) {
39
+ lines.push("状态:已暂停(ghprobe off)—— hosts 块已剥离,自愈停止写入;跑 ghprobe on 恢复");
40
+ }
41
+
36
42
  // ① hosts 块现状
37
43
  const gen = generatedAt(hostsContent);
38
44
  if (!current.entries.size) {
39
- lines.push("hosts:还没有 ghprobe 块 —— 先跑 ghprobe install");
45
+ // off 会主动剥块:此时不能说「先跑 install」(那是装没装的问题),要说「恢复」
46
+ lines.push(paused
47
+ ? "hosts:ghprobe 块已剥离(暂停状态,正常现象)—— 跑 ghprobe on 重新写回"
48
+ : "hosts:还没有 ghprobe 块 —— 先跑 ghprobe install");
40
49
  } else {
41
50
  lines.push(`hosts:${current.entries.size} 个域名(块生成于 ${gen ?? "?"})`);
42
51
  for (const [d, ip] of current.entries) {
@@ -83,6 +92,7 @@ export async function runStatus(cfg, deps = {}) {
83
92
  const hostsContent = deps.hostsContent ?? (() => { try { return fs.readFileSync(WIN_HOSTS, "utf8"); } catch { return ""; } })();
84
93
  const logTail = deps.logTail ?? readLogTail();
85
94
  const task = deps.taskQuery ? await deps.taskQuery() : null;
86
- const lines = buildStatusReport({ hostsContent, cfg, logTail, task });
95
+ const paused = deps.paused ?? isPaused();
96
+ const lines = buildStatusReport({ hostsContent, cfg, logTail, task, paused });
87
97
  return { lines, entries: parseBlock(hostsContent).entries.size };
88
98
  }
package/src/updates.js ADDED
@@ -0,0 +1,77 @@
1
+ // SPDX-License-Identifier: GPL-3.0-only
2
+ // Copyright (C) 2026 zmw
3
+ // 更新检查(0.1.6):npm 全局包不会自动升级,用户停在旧版本就吃不到自愈能力与新候选池
4
+ // 的改进 —— 而我们无从得知谁在用旧版。这里只做一件事:交互命令开头查一次 npm 上的
5
+ // latest,有新版就提示一行 `npm i -g ghprobe`。只提示,绝不自动升级(升级是用户的决定)。
6
+ //
7
+ // 纪律:
8
+ // · 每天最多拉一次(缓存 24h),且只在**交互命令**里查 —— daemon / 计划任务静默路径
9
+ // 绝不联网查版本,不能为提示增长每隔几十秒打一次 registry。
10
+ // · 查询源优先 npmmirror(国内网络可达性远好于 registry.npmjs.org),失败再试官方源。
11
+ // · 任何失败都静默 —— 提示功能本身不许打扰用户,更不许影响命令退出码。
12
+ import fs from "node:fs";
13
+ import path from "node:path";
14
+ import { userDataDir } from "./hosts.js";
15
+
16
+ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
17
+ const FETCH_TIMEOUT_MS = 5000;
18
+
19
+ export function updateCheckPath(dir = userDataDir()) {
20
+ return path.join(dir, "update.check.json");
21
+ }
22
+
23
+ // 语义化版本比较:只认 x.y.z 纯数字段(v 前缀容忍);带 prerelease / 解析不动的输入
24
+ // 一律返回 false(宁可漏提示,不误报)。
25
+ export function isNewer(latest, current) {
26
+ const parts = (v) => String(v || "").trim().replace(/^v/i, "").split(".").map((x) => parseInt(x, 10));
27
+ const a = parts(latest);
28
+ const b = parts(current);
29
+ if (a.length !== 3 || b.length !== 3 || a.some(Number.isNaN) || b.some(Number.isNaN)) { return false; }
30
+ for (let i = 0; i < 3; i += 1) {
31
+ if (a[i] !== b[i]) { return a[i] > b[i]; }
32
+ }
33
+ return false;
34
+ }
35
+
36
+ // 拉 latest:npmmirror 优先(国内可达),官方源兜底。5s 超时,全败返回 null。
37
+ async function fetchLatest(fetchImpl, pkgName) {
38
+ for (const url of [`https://registry.npmmirror.com/${pkgName}/latest`, `https://registry.npmjs.org/${pkgName}/latest`]) {
39
+ try {
40
+ const r = await fetchImpl(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
41
+ if (!r || !r.ok) { continue; }
42
+ const o = await r.json();
43
+ if (o && typeof o.version === "string" && o.version) { return o.version; }
44
+ } catch { /* 换下一个源 */ }
45
+ }
46
+ return null;
47
+ }
48
+
49
+ // 查最新版本并写缓存。deps 全可注入(fetchImpl / now / cachePath / pkgName)供单测。
50
+ export async function checkUpdate(deps = {}) {
51
+ const now = deps.now ?? Date.now();
52
+ const file = deps.cachePath ?? updateCheckPath();
53
+ const pkgName = deps.pkgName ?? "ghprobe";
54
+ let cache = null;
55
+ try { cache = JSON.parse(fs.readFileSync(file, "utf8")); } catch { /* 无缓存/坏缓存 */ }
56
+ if (cache && Number.isFinite(cache.at) && cache.latest && now - cache.at >= 0 && now - cache.at < CHECK_INTERVAL_MS) {
57
+ return { latest: cache.latest, cached: true };
58
+ }
59
+ const latest = await fetchLatest(deps.fetchImpl || fetch, pkgName);
60
+ if (latest) {
61
+ try {
62
+ fs.mkdirSync(path.dirname(file), { recursive: true });
63
+ fs.writeFileSync(file, JSON.stringify({ at: now, latest }));
64
+ } catch { /* 缓存写失败不影响本次提示 */ }
65
+ }
66
+ return { latest, cached: false };
67
+ }
68
+
69
+ // 交互命令开头调一下:有新版打印一行提示;任何失败静默。
70
+ export async function maybeNotifyUpdate(current, log = console.log, deps = {}) {
71
+ try {
72
+ const { latest } = await checkUpdate(deps);
73
+ if (latest && isNewer(latest, current)) {
74
+ log(`提示:ghprobe ${latest} 已发布,建议升级 —— npm i -g ghprobe(当前 ${current})`);
75
+ }
76
+ } catch { /* 更新检查失败不打扰 */ }
77
+ }