hunter-mate 0.1.5 → 0.1.7

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.
Files changed (123) hide show
  1. package/dist/assets/extension/background/service-worker.js +193 -16
  2. package/dist/assets/extension/manifest.json +1 -1
  3. package/dist/assets/extension/package.json +4 -3
  4. package/dist/assets/server/app.module.js +2 -1
  5. package/dist/assets/server/app.module.js.map +1 -1
  6. package/dist/assets/server/auth/auth.controller.d.ts +7 -0
  7. package/dist/assets/server/auth/auth.controller.js +20 -0
  8. package/dist/assets/server/auth/auth.controller.js.map +1 -1
  9. package/dist/assets/server/auth/auth.service.d.ts +11 -0
  10. package/dist/assets/server/auth/auth.service.js +58 -1
  11. package/dist/assets/server/auth/auth.service.js.map +1 -1
  12. package/dist/assets/server/build-info.json +1 -1
  13. package/dist/assets/server/extension/extension-control.controller.d.ts +3 -0
  14. package/dist/assets/server/extension/extension-control.controller.js +1 -1
  15. package/dist/assets/server/extension/extension-control.controller.js.map +1 -1
  16. package/dist/assets/server/extension/extension-control.service.d.ts +8 -0
  17. package/dist/assets/server/extension/extension-control.service.js +38 -1
  18. package/dist/assets/server/extension/extension-control.service.js.map +1 -1
  19. package/dist/assets/server/package.json +4 -3
  20. package/dist/assets/server/recording/recording-event-queue.service.d.ts +26 -0
  21. package/dist/assets/server/recording/recording-event-queue.service.js +115 -0
  22. package/dist/assets/server/recording/recording-event-queue.service.js.map +1 -0
  23. package/dist/assets/server/recording/recording-retention.service.d.ts +21 -0
  24. package/dist/assets/server/recording/recording-retention.service.js +85 -0
  25. package/dist/assets/server/recording/recording-retention.service.js.map +1 -0
  26. package/dist/assets/server/recording/recording.controller.d.ts +11 -1
  27. package/dist/assets/server/recording/recording.controller.js +18 -2
  28. package/dist/assets/server/recording/recording.controller.js.map +1 -1
  29. package/dist/assets/server/recording/recording.module.js +10 -1
  30. package/dist/assets/server/recording/recording.module.js.map +1 -1
  31. package/dist/assets/server/recording/recording.service.d.ts +5 -1
  32. package/dist/assets/server/recording/recording.service.js +21 -6
  33. package/dist/assets/server/recording/recording.service.js.map +1 -1
  34. package/dist/commands/cache.js +3 -4
  35. package/dist/commands/config.d.ts +6 -1
  36. package/dist/commands/doctor.js +120 -13
  37. package/dist/commands/extension.js +14 -23
  38. package/dist/commands/extras.js +2 -2
  39. package/dist/commands/job.d.ts +4 -1
  40. package/dist/commands/job.js +51 -16
  41. package/dist/commands/login.d.ts +15 -0
  42. package/dist/commands/login.js +57 -12
  43. package/dist/commands/ops.js +18 -2
  44. package/dist/commands/record.js +2 -4
  45. package/dist/commands/report.js +2 -4
  46. package/dist/commands/search.js +9 -2
  47. package/dist/commands/server.d.ts +2 -2
  48. package/dist/commands/server.js +78 -17
  49. package/dist/commands/update.js +1 -1
  50. package/dist/config.d.ts +36 -1
  51. package/dist/config.js +74 -5
  52. package/dist/i18n/locales/en-US.json +3 -1
  53. package/dist/i18n/locales/zh-CN.json +3 -1
  54. package/dist/index.js +38 -2
  55. package/dist/updater.d.ts +1 -1
  56. package/dist/updater.js +22 -5
  57. package/dist/utils/command-error.d.ts +9 -0
  58. package/dist/utils/command-error.js +31 -0
  59. package/dist/utils/proxy-detect.d.ts +20 -0
  60. package/dist/utils/proxy-detect.js +96 -0
  61. package/dist/utils/script-path.d.ts +5 -0
  62. package/dist/utils/script-path.js +27 -0
  63. package/dist/version.js +1 -1
  64. package/dist/watchdog.d.ts +23 -1
  65. package/dist/watchdog.js +95 -4
  66. package/dist/web/dist/404.html +1 -0
  67. package/dist/web/dist/_next/static/chunks/166.f30be223f694f8ee.js +1 -0
  68. package/dist/web/dist/_next/static/chunks/1e6d5a0c.0488932e17ecdf24.js +15 -0
  69. package/dist/web/dist/_next/static/chunks/539-5d0e8c5c238c56f8.js +1 -0
  70. package/dist/web/dist/_next/static/chunks/589-d632233641f35681.js +1 -0
  71. package/dist/web/dist/_next/static/chunks/671.3b8e9cc96b25b6ea.js +1 -0
  72. package/dist/web/dist/_next/static/chunks/935-99f57b7d4151d9e7.js +5 -0
  73. package/dist/web/dist/_next/static/chunks/a7067448-96dd4b8457c447e8.js +1 -0
  74. package/dist/web/dist/_next/static/chunks/app/_not-found/page-fdf1a13a7656886a.js +1 -0
  75. package/dist/web/dist/_next/static/chunks/app/auth/verify/page-8e5cff53d6f7d8e1.js +1 -0
  76. package/dist/web/dist/_next/static/chunks/app/companies/page-e38a69e1929d0953.js +1 -0
  77. package/dist/web/dist/_next/static/chunks/app/entity/page-250f8a9b6d91ab19.js +1 -0
  78. package/dist/web/dist/_next/static/chunks/app/jobs/page-6a281776fec065f6.js +1 -0
  79. package/dist/web/dist/_next/static/chunks/app/layout-83e623e973c1693f.js +1 -0
  80. package/dist/web/dist/_next/static/chunks/app/logs/page-9f41d44fa1add723.js +1 -0
  81. package/dist/web/dist/_next/static/chunks/app/matching/jobs/[id]/talents/page-f57f4b7529823601.js +1 -0
  82. package/dist/web/dist/_next/static/chunks/app/matching/portraits/[id]/jobs/page-614d1e62d7b9d215.js +1 -0
  83. package/dist/web/dist/_next/static/chunks/app/page-e3392de08ae81fba.js +1 -0
  84. package/dist/web/dist/_next/static/chunks/app/parselog/page-58d3acece95b4089.js +1 -0
  85. package/dist/web/dist/_next/static/chunks/app/recordings/page-e2e2e3c3a64083fb.js +1 -0
  86. package/dist/web/dist/_next/static/chunks/app/snapshots/[id]/agent-prompt/page-fd3b7d7373b8f8b7.js +1 -0
  87. package/dist/web/dist/_next/static/chunks/app/talents/page-30748338da20d7e0.js +1 -0
  88. package/dist/web/dist/_next/static/chunks/framework-b3a558b17a070965.js +1 -0
  89. package/dist/web/dist/_next/static/chunks/main-5a4e0cd75dd7f04b.js +1 -0
  90. package/dist/web/dist/_next/static/chunks/main-app-80b038a312917d90.js +1 -0
  91. package/dist/web/dist/_next/static/chunks/pages/_app-da7d628f49271ece.js +4 -0
  92. package/dist/web/dist/_next/static/chunks/pages/_error-17756f67e0a11350.js +1 -0
  93. package/dist/web/dist/_next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
  94. package/dist/web/dist/_next/static/chunks/webpack-1784baecc6fdcf0b.js +1 -0
  95. package/dist/web/dist/_next/static/css/db881c5b3072b88a.css +3 -0
  96. package/dist/web/dist/_next/static/css/f60d210841fab76b.css +1 -0
  97. package/dist/web/dist/_next/static/hqJ-7vr4X71DNfzZugHJ-/_buildManifest.js +1 -0
  98. package/dist/web/dist/_next/static/hqJ-7vr4X71DNfzZugHJ-/_ssgManifest.js +1 -0
  99. package/dist/web/dist/auth/verify.html +1 -0
  100. package/dist/web/dist/auth/verify.txt +8 -0
  101. package/dist/web/dist/companies.html +1 -0
  102. package/dist/web/dist/companies.txt +7 -0
  103. package/dist/web/dist/entity.html +1 -0
  104. package/dist/web/dist/entity.txt +8 -0
  105. package/dist/web/dist/index.html +1 -0
  106. package/dist/web/dist/index.txt +8 -0
  107. package/dist/web/dist/jobs.html +1 -0
  108. package/dist/web/dist/jobs.txt +7 -0
  109. package/dist/web/dist/logs.html +1 -0
  110. package/dist/web/dist/logs.txt +8 -0
  111. package/dist/web/dist/matching/jobs/placeholder/talents.html +1 -0
  112. package/dist/web/dist/matching/jobs/placeholder/talents.txt +8 -0
  113. package/dist/web/dist/matching/portraits/placeholder/jobs.html +1 -0
  114. package/dist/web/dist/matching/portraits/placeholder/jobs.txt +8 -0
  115. package/dist/web/dist/parselog.html +1 -0
  116. package/dist/web/dist/parselog.txt +8 -0
  117. package/dist/web/dist/recordings.html +1 -0
  118. package/dist/web/dist/recordings.txt +8 -0
  119. package/dist/web/dist/snapshots/placeholder/agent-prompt.html +1 -0
  120. package/dist/web/dist/snapshots/placeholder/agent-prompt.txt +8 -0
  121. package/dist/web/dist/talents.html +1 -0
  122. package/dist/web/dist/talents.txt +7 -0
  123. package/package.json +26 -6
@@ -3,6 +3,7 @@ import axios from 'axios';
3
3
  import { loadConfig } from '../config.js';
4
4
  import { getToken } from '../keychain.js';
5
5
  import { i18n } from '../i18n/index.js';
6
+ import { printCommandError } from '../utils/command-error.js';
6
7
  export async function runSearch(options) {
7
8
  const config = loadConfig();
8
9
  const token = await getToken();
@@ -34,6 +35,12 @@ export const searchCommand = new Command('search')
34
35
  .option('--source <sources>', i18n.t('search.source'), 'u_person')
35
36
  .option('--top <n>', i18n.t('search.top'), '10')
36
37
  .action(async (options) => {
37
- const data = await runSearch(options);
38
- console.log(JSON.stringify(data, null, 2));
38
+ try {
39
+ const data = await runSearch(options);
40
+ console.log(JSON.stringify(data, null, 2));
41
+ }
42
+ catch (err) {
43
+ printCommandError('search:failed', err);
44
+ process.exit(1);
45
+ }
39
46
  });
@@ -14,10 +14,10 @@ export declare function runStart(opts: {
14
14
  url: string;
15
15
  msg: string;
16
16
  }>;
17
- export declare function runStop(): {
17
+ export declare function runStop(): Promise<{
18
18
  stopped: boolean;
19
19
  pid: number | null;
20
20
  msg: string;
21
- };
21
+ }>;
22
22
  export declare const startCommand: Command;
23
23
  export declare const stopCommand: Command;
@@ -55,25 +55,50 @@ export function findServerRoot() {
55
55
  }
56
56
  return null;
57
57
  }
58
+ /** 探活:server /health 是否可访问(短超时,永不 throw) */
59
+ async function probeServerHealth(url, timeoutMs = 1500) {
60
+ try {
61
+ const res = await fetch(`${url}/health`, { signal: AbortSignal.timeout(timeoutMs) });
62
+ return res.ok;
63
+ }
64
+ catch {
65
+ return false;
66
+ }
67
+ }
68
+ /** 从 config.serverUrl 解析默认端口(与 runStart 逻辑一致) */
69
+ function defaultPortFromConfig(serverUrl) {
70
+ return new URL(serverUrl).port ? Number(new URL(serverUrl).port) : 34570;
71
+ }
58
72
  /** 对应 ChromePilot start: 启动本地 NestJS Server(提供 REST + WebSocket + SPA) */
59
73
  export async function runStart(opts) {
60
74
  const config = loadConfig();
61
- const defaultPort = new URL(config.serverUrl).port
62
- ? Number(new URL(config.serverUrl).port)
63
- : 34570;
75
+ const defaultPort = defaultPortFromConfig(config.serverUrl);
64
76
  const port = opts.port ?? defaultPort;
65
77
  const host = opts.host ?? '127.0.0.1';
66
78
  const url = `http://${host}:${port}`;
79
+ // 启动过程中的告警说明(会拼进最终返回 msg,保证用户看得见)
80
+ const notes = [];
67
81
  const existing = tryReadPid();
68
82
  if (existing) {
69
- // 已在运行: 先做版本一致性检测(磁盘版本 vs 运行版本,server 不一致则自动重启)
70
- // 动态 import 避免与 version.ts 的循环依赖(version.ts 静态引入本文件的 runStart/runStop)
71
- const { ensureRuntimeConsistency } = await import('../version.js');
72
- const report = await ensureRuntimeConsistency({ force: true });
73
- if (report.actions.serverRestart && report.performed.length) {
74
- return { started: true, pid: null, url, msg: report.performed.join('; ') };
83
+ // pid 文件存在 ≠ server 存活。pid 可能被系统中其他进程复用
84
+ // (Windows 现场:残留 node.exe 复用了 pid → 误判 already running,
85
+ // server 实际从未被拉起,日志停在旧时间戳,用户无任何感知)。
86
+ // 因此必须先 /health 探活再决定是否走"已在运行"分支。
87
+ if (await probeServerHealth(url)) {
88
+ // 已在运行: 先做版本一致性检测(磁盘版本 vs 运行版本,server 不一致则自动重启)
89
+ // 动态 import 避免与 version.ts 的循环依赖(version.ts 静态引入本文件的 runStart/runStop)
90
+ const { ensureRuntimeConsistency } = await import('../version.js');
91
+ const report = await ensureRuntimeConsistency({ force: true });
92
+ if (report.actions.serverRestart && report.performed.length) {
93
+ return { started: true, pid: null, url, msg: report.performed.join('; ') };
94
+ }
95
+ return { started: false, pid: existing, url, msg: `server already running (pid=${existing})` };
75
96
  }
76
- return { started: false, pid: existing, url, msg: `server already running (pid=${existing})` };
97
+ // stale pid:进程号存活但不是我们的 server(或 server 已僵死)。清理后继续启动,
98
+ // 不 kill —— 那个进程可能属于别的程序(误杀 foreign 进程红线)。
99
+ notes.push(`⚠ 检测到 stale server.pid (pid=${existing}):该进程存活但 ${url}/health 无响应,` +
100
+ `疑似 pid 被其他进程复用,已忽略并继续启动`);
101
+ cleanPidFile(PID_FILE());
77
102
  }
78
103
  // server 入口: bundled 模式下 <cli>/dist/assets/server/main.js;monorepo 下 packages/server/dist/main.js 或 src/main.ts(开发模式 ts-node)
79
104
  const serverRoot = findServerRoot();
@@ -150,8 +175,11 @@ export async function runStart(opts) {
150
175
  wd.unref();
151
176
  if (wd.pid)
152
177
  fs.writeFileSync(WATCHDOG_PID_FILE(), String(wd.pid));
153
- // 等待 server health 就绪(最多 8s)
154
- const deadline = Date.now() + 8000;
178
+ // 等待 server health 就绪。窗口给足:NestJS 全量路由映射 + TypeORM schema 同步
179
+ // 在慢机/Windows 上实测 10-15s,原 8s 会在 route mapping 阶段误报"未就绪"
180
+ // (server 实际 3 秒后就绪,watchdog 会保活,误报只会误导用户)。
181
+ const probeDeadlineMs = 30_000;
182
+ const deadline = Date.now() + probeDeadlineMs;
155
183
  let ready = false;
156
184
  while (Date.now() < deadline) {
157
185
  await new Promise((r) => setTimeout(r, 500));
@@ -179,7 +207,8 @@ export async function runStart(opts) {
179
207
  started: false,
180
208
  pid: wd.pid ?? null,
181
209
  url,
182
- msg: `✗ server watchdog 启动后未就绪(pid=${wd.pid}),日志尾部:\n${tail || '(server.log 为空)'}\n` +
210
+ msg: (notes.length ? notes.join('\n') + '\n' : '') +
211
+ `✗ server watchdog 启动后 ${probeDeadlineMs / 1000}s 内未就绪(pid=${wd.pid}),日志尾部:\n${tail || '(server.log 为空)'}\n` +
183
212
  `排障: hunter-mate doctor 或 TROUBLESHOOTING.md#server-启动即崩abi`,
184
213
  };
185
214
  }
@@ -193,12 +222,34 @@ export async function runStart(opts) {
193
222
  }
194
223
  }
195
224
  catch { /* ignore */ }
196
- return { started: true, pid: wd.pid ?? null, url, msg: `server watchdog started (pid=${wd.pid}) on ${url} — logs: ${LOG_FILE()}` };
225
+ return {
226
+ started: true,
227
+ pid: wd.pid ?? null,
228
+ url,
229
+ msg: (notes.length ? notes.join('\n') + '\n' : '') +
230
+ `server watchdog started (pid=${wd.pid}) on ${url} — logs: ${LOG_FILE()}`,
231
+ };
197
232
  }
198
- export function runStop() {
233
+ export async function runStop() {
234
+ const config = loadConfig();
235
+ const url = `http://127.0.0.1:${defaultPortFromConfig(config.serverUrl)}`;
236
+ // kill 任何进程前先 /health 确认它真的是我们的 server。
237
+ // pid 文件可能被复用(Windows 现场:残留 node.exe 占用 pid),误杀 foreign 进程不可接受。
238
+ const healthy = await probeServerHealth(url);
199
239
  // 优先停止 watchdog,让它去停止子 server
200
240
  const wdPid = tryReadPidFile(WATCHDOG_PID_FILE());
201
241
  if (wdPid) {
242
+ if (!healthy) {
243
+ // server 不响应时不杀 watchdog pid(可能已被其他进程复用),只清理 pid 文件
244
+ cleanPidFile(WATCHDOG_PID_FILE());
245
+ cleanPidFile(PID_FILE());
246
+ return {
247
+ stopped: false,
248
+ pid: wdPid,
249
+ msg: `✗ server 无响应(${url}/health 不可达),watchdog pid=${wdPid} 未杀(疑似 stale pid,` +
250
+ `可能已被其他进程复用)。已清理 pid 文件;如确有残留进程请手工确认后 kill`,
251
+ };
252
+ }
202
253
  const ok = killGracefullySync(wdPid);
203
254
  cleanPidFile(WATCHDOG_PID_FILE());
204
255
  if (ok) {
@@ -218,6 +269,16 @@ export function runStop() {
218
269
  const pid = tryReadPid();
219
270
  if (!pid)
220
271
  return { stopped: false, pid: null, msg: 'no server.pid found or process dead' };
272
+ if (!healthy) {
273
+ // pid 存活但 server 不响应:不 kill(可能复用了别人的 pid),只清 pid 文件
274
+ cleanPidFile(PID_FILE());
275
+ return {
276
+ stopped: false,
277
+ pid,
278
+ msg: `✗ pid=${pid} 存活但 server 无响应,未发送 kill 信号(疑似 stale pid,` +
279
+ `可能属于其他进程)。已清理 pid 文件;如确认是僵死 server 请手工 kill`,
280
+ };
281
+ }
221
282
  const ok = killGracefullySync(pid);
222
283
  if (!ok) {
223
284
  return { stopped: false, pid, msg: `failed to signal ${pid}` };
@@ -238,8 +299,8 @@ export const startCommand = new Command('start')
238
299
  });
239
300
  export const stopCommand = new Command('stop')
240
301
  .description('停止本地 server(读 ~/.hunter-mate/server.pid 并 SIGTERM)')
241
- .action(() => {
242
- const r = runStop();
302
+ .action(async () => {
303
+ const r = await runStop();
243
304
  console.log(r.msg);
244
305
  if (!r.stopped && r.pid)
245
306
  process.exit(1);
@@ -105,7 +105,7 @@ export const updateCommand = new Command('update')
105
105
  }
106
106
  else {
107
107
  console.log(chalk.cyan(`CLI 自更新: ${installed.cli} → ${latest}`));
108
- const r = npmSelfUpdate(npmPackage);
108
+ const r = await npmSelfUpdate(npmPackage);
109
109
  console.log(r.ok ? chalk.green(r.msg) : chalk.yellow(r.msg));
110
110
  }
111
111
  }
package/dist/config.d.ts CHANGED
@@ -6,6 +6,41 @@ export interface HunterMateConfig {
6
6
  lang: string;
7
7
  watchFolder?: string;
8
8
  }
9
+ export declare const CONFIG_DIR: () => string;
10
+ export declare const CONFIG_FILE: () => string;
11
+ /**
12
+ * 已知废弃的 URL 路径前缀(按部署形态演进顺序追加)。
13
+ * 每当 nginx / 服务端路径收敛(如子路径 → 根路径)时,把旧前缀加到这里,
14
+ * loadConfig() / saveConfig() 会自动剥离,doctor 会自检并给出 healed 记录。
15
+ * 匹配规则:字符串中任何一段形如 `{host}{prefix}` 都会被替换为 `{host}/`,
16
+ * 且自动处理尾斜杠(例如 https://host/llm-research/foo → https://host/foo)。
17
+ */
18
+ export declare const DEPRECATED_URL_PATH_PREFIXES: string[];
19
+ /**
20
+ * 规范化 URL 字符串: 剥离所有已知废弃的子路径前缀。
21
+ * 例: normalizeUrlValue('https://dev-ops.hiredchina.com/llm-research/errors')
22
+ * → 'https://dev-ops.hiredchina.com/errors'
23
+ */
24
+ export declare function normalizeUrlValue(value: string): string;
25
+ /**
26
+ * 对 config 对象所有 string 字段应用 URL 规范化。
27
+ * 返回 { config, changed } 便于调用方决定是否持久化。
28
+ * 深度 1 层足够(配置文件是扁平 key-value)。
29
+ */
30
+ export declare function normalizeConfig<T extends Record<string, any>>(raw: T): {
31
+ config: T;
32
+ changed: boolean;
33
+ migrated: Array<{
34
+ key: string;
35
+ from: string;
36
+ to: string;
37
+ }>;
38
+ };
9
39
  export declare function ensureConfigDir(): void;
10
40
  export declare function loadConfig(): HunterMateConfig;
11
- export declare function saveConfig(config: Partial<HunterMateConfig>): HunterMateConfig;
41
+ export declare function saveConfig(config: Partial<HunterMateConfig>): {
42
+ serverUrl: string;
43
+ saasUrl: string;
44
+ lang: string;
45
+ watchFolder?: string;
46
+ };
package/dist/config.js CHANGED
@@ -2,13 +2,67 @@ import * as fs from 'fs';
2
2
  import * as path from 'path';
3
3
  import { createJsonConfigStore } from '@chromepilot/kit';
4
4
  const HOME = () => process.env.HUNTER_MATE_HOME || process.env.HOME || process.env.USERPROFILE || '.';
5
- const CONFIG_DIR = () => path.join(HOME(), '.hunter-mate');
6
- const CONFIG_FILE = () => path.join(CONFIG_DIR(), 'config.json');
5
+ export const CONFIG_DIR = () => path.join(HOME(), '.hunter-mate');
6
+ export const CONFIG_FILE = () => path.join(CONFIG_DIR(), 'config.json');
7
+ /**
8
+ * 已知废弃的 URL 路径前缀(按部署形态演进顺序追加)。
9
+ * 每当 nginx / 服务端路径收敛(如子路径 → 根路径)时,把旧前缀加到这里,
10
+ * loadConfig() / saveConfig() 会自动剥离,doctor 会自检并给出 healed 记录。
11
+ * 匹配规则:字符串中任何一段形如 `{host}{prefix}` 都会被替换为 `{host}/`,
12
+ * 且自动处理尾斜杠(例如 https://host/llm-research/foo → https://host/foo)。
13
+ */
14
+ export const DEPRECATED_URL_PATH_PREFIXES = [
15
+ '/llm-research', // 2026-08-21 dev-ops 从子路径部署收敛到根路径
16
+ ];
17
+ /**
18
+ * 规范化 URL 字符串: 剥离所有已知废弃的子路径前缀。
19
+ * 例: normalizeUrlValue('https://dev-ops.hiredchina.com/llm-research/errors')
20
+ * → 'https://dev-ops.hiredchina.com/errors'
21
+ */
22
+ export function normalizeUrlValue(value) {
23
+ if (typeof value !== 'string')
24
+ return value;
25
+ let out = value;
26
+ for (const prefix of DEPRECATED_URL_PATH_PREFIXES) {
27
+ // 匹配 host/<prefix> 形式,支持 <prefix>/... 和 <prefix> 结尾
28
+ // e.g. https://host/llm-research → https://host/
29
+ // https://host/llm-research/x → https://host/x
30
+ const re = new RegExp(`(${prefix})(/|$)`, 'g');
31
+ out = out.replace(re, '/$2');
32
+ }
33
+ // 合并多余的连续斜杠(上面的替换可能产生 "//")
34
+ // 协议里的 :// 不动,只处理 host 之后的部分
35
+ out = out.replace(/^(https?:\/\/)(.*)/, (_m, scheme, rest) => {
36
+ return scheme + rest.replace(/\/{2,}/g, '/');
37
+ });
38
+ return out;
39
+ }
40
+ /**
41
+ * 对 config 对象所有 string 字段应用 URL 规范化。
42
+ * 返回 { config, changed } 便于调用方决定是否持久化。
43
+ * 深度 1 层足够(配置文件是扁平 key-value)。
44
+ */
45
+ export function normalizeConfig(raw) {
46
+ const migrated = [];
47
+ const out = { ...raw };
48
+ for (const key of Object.keys(out)) {
49
+ const value = out[key];
50
+ // 覆盖类型定义之外的自定义字段(如用户手动添加的 apiBase 等)
51
+ if (typeof value === 'string' && (value.startsWith('http://') || value.startsWith('https://') || value.includes('/llm-research'))) {
52
+ const normalized = normalizeUrlValue(value);
53
+ if (normalized !== value) {
54
+ migrated.push({ key, from: value, to: normalized });
55
+ out[key] = normalized;
56
+ }
57
+ }
58
+ }
59
+ return { config: out, changed: migrated.length > 0, migrated };
60
+ }
7
61
  const store = createJsonConfigStore({
8
62
  configPath: CONFIG_FILE,
9
63
  defaults: {
10
64
  serverUrl: 'http://localhost:34570',
11
- saasUrl: 'https://dev-ops.hiredchina.com/llm-research',
65
+ saasUrl: 'https://dev-ops.hiredchina.com',
12
66
  lang: 'zh-CN',
13
67
  },
14
68
  });
@@ -18,8 +72,23 @@ export function ensureConfigDir() {
18
72
  }
19
73
  }
20
74
  export function loadConfig() {
21
- return store.load();
75
+ const raw = store.load();
76
+ const { config: normalized, changed } = normalizeConfig(raw);
77
+ if (changed) {
78
+ // 配置含有已废弃路径,自动静默迁移并保存,用户下次读取一定是规范形态。
79
+ try {
80
+ fs.writeFileSync(CONFIG_FILE(), JSON.stringify(normalized, null, 2));
81
+ }
82
+ catch {
83
+ /* read-only fs, ignore */
84
+ }
85
+ }
86
+ return normalized;
22
87
  }
23
88
  export function saveConfig(config) {
24
- return store.save(config);
89
+ const current = loadConfig(); // 已规范化(必要时已写回)
90
+ const { config: next } = normalizeConfig({ ...current, ...config });
91
+ ensureConfigDir();
92
+ fs.writeFileSync(CONFIG_FILE(), JSON.stringify(next, null, 2));
93
+ return next;
25
94
  }
@@ -57,5 +57,7 @@
57
57
  "purge.done.removed": "Removed",
58
58
  "purge.done.server": "Server",
59
59
  "purge.done.next": "To re-initialize, run",
60
- "purge.done.nothing": "Nothing to purge"
60
+ "purge.done.nothing": "Nothing to purge",
61
+ "login.serverOfflineSaasFallback": "Warning: local server is not running; logged in via SaaS directly. Start the server and run login again for local data operations.",
62
+ "login.serverOfflineReloginHint": "Note: the saved SaaS token will be rejected (401) by local server protected routes."
61
63
  }
@@ -57,5 +57,7 @@
57
57
  "purge.done.removed": "清理数据量",
58
58
  "purge.done.server": "Server 状态",
59
59
  "purge.done.next": "如需重新初始化,请运行",
60
- "purge.done.nothing": "无需清理"
60
+ "purge.done.nothing": "无需清理",
61
+ "login.serverOfflineSaasFallback": "警告: 本地 server 未运行,已直连 SaaS 登录;本地数据操作需启动 server 后重新 login。",
62
+ "login.serverOfflineReloginHint": "提示: 本次保存的是 SaaS 令牌,本地 server 的受保护路由将返回 401。"
61
63
  }
package/dist/index.js CHANGED
@@ -26,6 +26,25 @@ import { i18n } from './i18n/index.js';
26
26
  import { initCliLogger, flushCliLogger, getCliLogger } from './logger.js';
27
27
  import packageJson from '../package.json' with { type: 'json' };
28
28
  const CLI_VERSION = packageJson.version;
29
+ // 未捕获异常兜底(fix #27):任何命令抛出的未处理 Promise 统一友好提示 + exit 1,
30
+ // 不再裸奔 stack 或以 exit code 0 静默结束。
31
+ process.on('unhandledRejection', (reason) => {
32
+ const status = reason?.response?.status;
33
+ const message = reason?.response?.data?.message || reason?.response?.data?.error || reason?.message || String(reason);
34
+ if (status === 401 || status === 403) {
35
+ console.error('登录已过期或无权访问,请运行 hunter-mate login 后重试');
36
+ }
37
+ else {
38
+ console.error(`命令执行出错: ${message}`);
39
+ }
40
+ try {
41
+ getCliLogger().error('cli:unhandled-rejection', { status, error: message });
42
+ }
43
+ catch {
44
+ // logger 未就绪时忽略
45
+ }
46
+ process.exit(1);
47
+ });
29
48
  initSentry();
30
49
  // patchConsole + patchStreams:把业务代码里的 console.error/warn 统一转发到 logger-kit,
31
50
  // 避免两套日志写法。AGENTS.md §11。
@@ -55,16 +74,33 @@ program
55
74
  // 2) server 在跑时对比磁盘版本 vs 运行版本,插件不一致 → reload,server 不一致 → 重启。
56
75
  // HUNTER_MATE_NO_AUTOUPDATE=1 可禁用;update/start/stop 自行处理避免递归;
57
76
  // server 未运行时一致性零成本跳过(本地 pid 探测,60s 节流)。
77
+ // doctor 跳过(fix #13): doctor 是只读自检, 不应触发 extension reload 广播,
78
+ // 也不应让 auto-doctor 以 repair 模式写数据(repair-linkedin-dupes 同属此类)。
58
79
  if (process.env.HUNTER_MATE_NO_AUTOUPDATE === '1')
59
80
  return;
60
81
  const name = thisCommand.name();
61
82
  if (name === 'update' || name === 'start' || name === 'stop')
62
83
  return;
84
+ if (name === 'doctor' || name === 'repair-linkedin-dupes')
85
+ return;
86
+ // fix #30: 命令以 --json(或 --format/-f json)输出时, [auto-update] 提示不得混入 stdout 的 JSON
87
+ const argv = process.argv;
88
+ const jsonMode = argv.includes('--json') ||
89
+ argv.includes('--format=json') ||
90
+ argv.includes('-f=json') ||
91
+ argv.some((a, i) => (a === '--format' || a === '-f') && argv[i + 1] === 'json');
92
+ const notify = (line) => {
93
+ const text = chalk.gray(` [auto-update] ${line}`);
94
+ if (jsonMode)
95
+ console.error(text);
96
+ else
97
+ console.log(text);
98
+ };
63
99
  try {
64
100
  const { maybeCliSelfUpdate } = await import('./updater.js');
65
101
  const upd = await maybeCliSelfUpdate();
66
102
  for (const line of upd.logs)
67
- console.log(chalk.gray(` [auto-update] ${line}`));
103
+ notify(line);
68
104
  }
69
105
  catch {
70
106
  /* CLI 自更新检测永不阻塞主命令 */
@@ -74,7 +110,7 @@ program
74
110
  const report = await ensureRuntimeConsistency({});
75
111
  if (report.performed.length) {
76
112
  for (const line of report.performed)
77
- console.log(chalk.gray(` [auto-update] ${line}`));
113
+ notify(line);
78
114
  }
79
115
  try {
80
116
  const { runAutoDoctor } = await import('./auto-doctor.js');
package/dist/updater.d.ts CHANGED
@@ -13,7 +13,7 @@ export interface NpmSelfUpdateResult {
13
13
  ok: boolean;
14
14
  msg: string;
15
15
  }
16
- export declare function npmSelfUpdate(pkg?: string): NpmSelfUpdateResult;
16
+ export declare function npmSelfUpdate(pkg?: string): Promise<NpmSelfUpdateResult>;
17
17
  export interface CliUpdateOutcome {
18
18
  logs: string[];
19
19
  devSkipped: boolean;
package/dist/updater.js CHANGED
@@ -52,17 +52,34 @@ export const npmLatestVersion = kitUpdater.npmLatestVersion;
52
52
  export const updateCheckIntervalMs = kitUpdater.updateCheckIntervalMs;
53
53
  export const shouldCheckUpdate = kitUpdater.shouldCheckUpdate;
54
54
  export const markUpdateChecked = kitUpdater.markUpdateChecked;
55
- export function npmSelfUpdate(pkg = 'hunter-mate') {
55
+ export async function npmSelfUpdate(pkg = 'hunter-mate') {
56
56
  const prefix = CLI_PREFIX();
57
57
  fs.mkdirSync(prefix, { recursive: true });
58
58
  // 优先使用用户通过环境变量指定的 npm 路径(沙盒内可能只有局部 npm)
59
59
  const npmBin = process.env.HUNTER_MATE_NPM_BIN || 'npm';
60
60
  const args = ['install', `${pkg}@latest`, '--prefix', prefix];
61
+ // 死代理处理:npm 会继承 HTTP(S)_PROXY;指向死端口时 registry 查询必失败
62
+ // (现场真凶:update 查 registry 失败 + prebuild 下载失败回退 7 分钟源码编译)。
63
+ // 检测后从子进程环境剔除死代理变量,让 npm 直连,并在结果里告知用户。
64
+ const childEnv = { ...process.env };
65
+ let proxyNote = '';
66
+ try {
67
+ const { findDeadProxies } = await import('./utils/proxy-detect.js');
68
+ const dead = await findDeadProxies(800);
69
+ if (dead.length) {
70
+ for (const d of dead)
71
+ delete childEnv[d.varName];
72
+ proxyNote =
73
+ `⚠ 检测到死代理(${dead.map((d) => `${d.varName}=${d.value}`).join('; ')}),` +
74
+ `已在本轮 npm install 中临时剔除使其直连;清除方法见 hunter-mate doctor 的 proxy_env 检查项\n`;
75
+ }
76
+ }
77
+ catch { /* 检测失败不阻断更新 */ }
61
78
  const r = spawnSync(npmBin, args, {
62
79
  stdio: 'inherit',
63
80
  timeout: 120_000,
64
81
  env: {
65
- ...process.env,
82
+ ...childEnv,
66
83
  // 防止 npm 因为用户 home 不可写而失败;--prefix 已指定安装位置
67
84
  npm_config_cache: path.join(prefix, '.npm-cache'),
68
85
  },
@@ -70,12 +87,12 @@ export function npmSelfUpdate(pkg = 'hunter-mate') {
70
87
  if (r.status === 0) {
71
88
  return {
72
89
  ok: true,
73
- msg: `${npmBin} install ${pkg}@latest --prefix ${prefix} 完成,下一条命令起使用新版本`,
90
+ msg: proxyNote + `${npmBin} install ${pkg}@latest --prefix ${prefix} 完成,下一条命令起使用新版本`,
74
91
  };
75
92
  }
76
93
  return {
77
94
  ok: false,
78
- msg: `${npmBin} install ${pkg}@latest --prefix ${prefix} 失败 (exit=${r.status}),可手动执行`,
95
+ msg: proxyNote + `${npmBin} install ${pkg}@latest --prefix ${prefix} 失败 (exit=${r.status}),可手动执行`,
79
96
  };
80
97
  }
81
98
  /**
@@ -107,7 +124,7 @@ export async function maybeCliSelfUpdate() {
107
124
  if (latest === installed)
108
125
  return outcome;
109
126
  logs.push(`CLI 自更新: ${installed} → ${latest}`);
110
- const r = npmSelfUpdate();
127
+ const r = await npmSelfUpdate();
111
128
  logs.push(r.msg);
112
129
  outcome.updated = r.ok;
113
130
  }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * 命令错误统一出口(对应 fix #18/#28):
3
+ * - logger 留痕(后台可检索,与现有 getCliLogger().error 一致)
4
+ * - console.error 落终端(用户能直接看懂,不再静默 exit 1)
5
+ * - 401/403 时给出重新登录的中文提示
6
+ *
7
+ * 调用方随后自行 process.exit(1)。
8
+ */
9
+ export declare function printCommandError(scope: string, err: unknown): void;
@@ -0,0 +1,31 @@
1
+ import { getCliLogger } from '../logger.js';
2
+ /**
3
+ * 命令错误统一出口(对应 fix #18/#28):
4
+ * - logger 留痕(后台可检索,与现有 getCliLogger().error 一致)
5
+ * - console.error 落终端(用户能直接看懂,不再静默 exit 1)
6
+ * - 401/403 时给出重新登录的中文提示
7
+ *
8
+ * 调用方随后自行 process.exit(1)。
9
+ */
10
+ export function printCommandError(scope, err) {
11
+ const e = err;
12
+ const status = e?.response?.status;
13
+ const body = e?.response?.data;
14
+ const bodyText = body ? (typeof body === 'string' ? body : JSON.stringify(body)) : '';
15
+ const message = e?.response?.data?.message || e?.response?.data?.error || e?.message || String(err);
16
+ try {
17
+ getCliLogger().error(scope, { status, error: message });
18
+ }
19
+ catch {
20
+ // logger 未就绪时忽略
21
+ }
22
+ if (status === 401 || status === 403) {
23
+ console.error(`✗ ${message} (HTTP ${status})`);
24
+ console.error('登录已过期或无权访问,请运行 hunter-mate login 后重试');
25
+ return;
26
+ }
27
+ console.error(`✗ ${scope}: ${message}${status ? ` (HTTP ${status})` : ''}`);
28
+ if (bodyText && bodyText !== message) {
29
+ console.error(` 响应: ${bodyText}`);
30
+ }
31
+ }
@@ -0,0 +1,20 @@
1
+ export interface ProxyEnvEntry {
2
+ varName: string;
3
+ value: string;
4
+ }
5
+ export interface DeadProxyFinding {
6
+ varName: string;
7
+ value: string;
8
+ host: string;
9
+ port: number;
10
+ reason: 'refused' | 'timeout' | 'unreachable';
11
+ }
12
+ /** 收集四个代理环境变量中已设置的项 */
13
+ export declare function collectProxyEnv(): ProxyEnvEntry[];
14
+ /**
15
+ * 找出指向 loopback 但端口不通的死代理。
16
+ * 远程代理(非 loopback)不探测,直接视为有效(由网络栈自己处理)。
17
+ */
18
+ export declare function findDeadProxies(timeoutMs?: number): Promise<DeadProxyFinding[]>;
19
+ /** 生成各 shell 的清除指令提示(用户直接复制执行) */
20
+ export declare function buildUnsetHint(findings: DeadProxyFinding[]): string;
@@ -0,0 +1,96 @@
1
+ /**
2
+ * 死代理检测 — Windows/终端现场高频坑。
3
+ *
4
+ * 背景:用户的 shell 环境变量里可能残留指向已关闭本地代理的
5
+ * HTTP_PROXY / HTTPS_PROXY / http_proxy / https_proxy(如 http://127.0.0.1:1962)。
6
+ * 后果链(2026-09-02 现场):
7
+ * npm registry 查询失败 → CLI 自更新误判"已是最新"或报错;
8
+ * better-sqlite3 prebuild 下载失败 → 回退 node-gyp 源码编译 7 分钟+。
9
+ * 本模块只检测 loopback 代理(远程代理探测需要出网,不属于"死代理"语义),
10
+ * 且永不 throw —— 检测结果用于告警,不影响主流程。
11
+ */
12
+ import net from 'node:net';
13
+ const PROXY_VARS = ['HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy'];
14
+ /** 收集四个代理环境变量中已设置的项 */
15
+ export function collectProxyEnv() {
16
+ const out = [];
17
+ for (const varName of PROXY_VARS) {
18
+ const value = process.env[varName];
19
+ if (value)
20
+ out.push({ varName, value });
21
+ }
22
+ return out;
23
+ }
24
+ /** 解析代理值为 {host, port}(仅处理显式端口的 http(s) 代理) */
25
+ function parseProxyUrl(value) {
26
+ try {
27
+ const u = new URL(value);
28
+ if ((u.protocol !== 'http:' && u.protocol !== 'https:') || !u.port)
29
+ return null;
30
+ return { host: u.hostname, port: Number(u.port) };
31
+ }
32
+ catch {
33
+ return null;
34
+ }
35
+ }
36
+ function isLoopback(host) {
37
+ return host === '127.0.0.1' || host === 'localhost' || host === '::1' || host === '[::1]';
38
+ }
39
+ function probePort(host, port, timeoutMs) {
40
+ return new Promise((resolve) => {
41
+ const socket = new net.Socket();
42
+ let settled = false;
43
+ const done = (r) => {
44
+ if (settled)
45
+ return;
46
+ settled = true;
47
+ socket.destroy();
48
+ resolve(r);
49
+ };
50
+ socket.setTimeout(timeoutMs);
51
+ socket.once('connect', () => done('open'));
52
+ socket.once('timeout', () => done('timeout'));
53
+ socket.once('error', (err) => done(err.code === 'ECONNREFUSED' ? 'refused' : 'unreachable'));
54
+ try {
55
+ socket.connect(port, host);
56
+ }
57
+ catch {
58
+ done('unreachable');
59
+ }
60
+ });
61
+ }
62
+ /**
63
+ * 找出指向 loopback 但端口不通的死代理。
64
+ * 远程代理(非 loopback)不探测,直接视为有效(由网络栈自己处理)。
65
+ */
66
+ export async function findDeadProxies(timeoutMs = 800) {
67
+ const findings = [];
68
+ for (const { varName, value } of collectProxyEnv()) {
69
+ const parsed = parseProxyUrl(value);
70
+ if (!parsed || !isLoopback(parsed.host))
71
+ continue;
72
+ const result = await probePort(parsed.host, parsed.port, timeoutMs);
73
+ if (result !== 'open') {
74
+ findings.push({
75
+ varName,
76
+ value,
77
+ host: parsed.host,
78
+ port: parsed.port,
79
+ reason: result === 'refused' ? 'refused' : result === 'timeout' ? 'timeout' : 'unreachable',
80
+ });
81
+ }
82
+ }
83
+ return findings;
84
+ }
85
+ /** 生成各 shell 的清除指令提示(用户直接复制执行) */
86
+ export function buildUnsetHint(findings) {
87
+ const vars = [...new Set(findings.map((f) => f.varName))].join(', ');
88
+ return [
89
+ `死代理告警: ${findings.map((f) => `${f.varName}=${f.value} (${f.reason === 'refused' ? '连接被拒绝' : f.reason === 'timeout' ? '连接超时' : '不可达'})`).join('; ')}`,
90
+ `该代理端口已不可用,会导致 npm registry 查询失败 / prebuild 下载失败回退源码编译。清除(当前会话):`,
91
+ ` PowerShell: Remove-Item Env:${vars.replace(/, /g, ', Env:')}`,
92
+ ` CMD: set ${findings.map((f) => f.varName).join('= && set ')}=`,
93
+ ` bash/zsh: unset ${findings.map((f) => f.varName).join(' ')}`,
94
+ ` npm 专用: npm config delete proxy && npm config delete https-proxy`,
95
+ ].join('\n');
96
+ }