hunter-mate 0.1.8 → 0.1.11
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/dist/assets/extension/background/service-worker.js +88 -8
- package/dist/assets/extension/build-info.json +1 -1
- package/dist/assets/extension/content/index.js +1 -1
- package/dist/assets/extension/manifest.json +1 -1
- package/dist/assets/extension/offscreen/offscreen.js +1 -1
- package/dist/assets/extension/sidepanel/index.css +1 -1
- package/dist/assets/extension/sidepanel/index.js +7 -7
- package/dist/assets/server/build-info.json +1 -1
- package/dist/assets/server/extension/extension-control.controller.d.ts +5 -1
- package/dist/assets/server/extension/extension-control.controller.js +29 -13
- package/dist/assets/server/extension/extension-control.controller.js.map +1 -1
- package/dist/assets/server/extension/extension.module.js +2 -1
- package/dist/assets/server/extension/extension.module.js.map +1 -1
- package/dist/assets/server/extraction/extraction.service.js +12 -9
- package/dist/assets/server/extraction/extraction.service.js.map +1 -1
- package/dist/assets/server/extraction/script-rule.js +10 -2
- package/dist/assets/server/extraction/script-rule.js.map +1 -1
- package/dist/assets/server/governance/governance.command-recorder.d.ts +17 -0
- package/dist/assets/server/governance/governance.command-recorder.js +98 -0
- package/dist/assets/server/governance/governance.command-recorder.js.map +1 -0
- package/dist/assets/server/governance/governance.ingester.d.ts +1 -0
- package/dist/assets/server/governance/governance.ingester.js +6 -3
- package/dist/assets/server/governance/governance.ingester.js.map +1 -1
- package/dist/assets/server/governance/governance.module.js +3 -2
- package/dist/assets/server/governance/governance.module.js.map +1 -1
- package/dist/assets/server/governance/governance.service.d.ts +8 -0
- package/dist/assets/server/governance/governance.service.js +42 -0
- package/dist/assets/server/governance/governance.service.js.map +1 -1
- package/dist/assets/server/recording/recording.gateway.js +4 -0
- package/dist/assets/server/recording/recording.gateway.js.map +1 -1
- package/dist/commands/extension.js +3 -1
- package/dist/commands/extras.d.ts +2 -6
- package/dist/commands/extras.js +90 -42
- package/dist/commands/install.d.ts +5 -0
- package/dist/commands/install.js +22 -42
- package/dist/commands/login.js +27 -1
- package/dist/commands/server.d.ts +5 -0
- package/dist/commands/server.js +142 -16
- package/dist/extension-sync.d.ts +35 -0
- package/dist/extension-sync.js +159 -0
- package/dist/index.js +3 -0
- package/dist/postinstall.d.ts +1 -0
- package/dist/postinstall.js +31 -0
- package/dist/utils/artifact-checksum.d.ts +4 -0
- package/dist/utils/artifact-checksum.js +40 -0
- package/dist/utils/local-http.d.ts +7 -0
- package/dist/utils/local-http.js +30 -0
- package/dist/version.js +53 -41
- package/dist/watchdog.d.ts +8 -0
- package/dist/watchdog.js +46 -6
- package/dist/web/dist/404.html +1 -1
- package/dist/web/dist/_next/static/chunks/{main-app-80b038a312917d90.js → main-app-0af848156a5b13fc.js} +1 -1
- package/dist/web/dist/_next/static/chunks/pages/{_app-da7d628f49271ece.js → _app-3d844792ec91e4d1.js} +2 -2
- package/dist/web/dist/auth/verify.html +1 -1
- package/dist/web/dist/auth/verify.txt +1 -1
- package/dist/web/dist/companies.html +1 -1
- package/dist/web/dist/companies.txt +1 -1
- package/dist/web/dist/entity.html +1 -1
- package/dist/web/dist/entity.txt +1 -1
- package/dist/web/dist/index.html +1 -1
- package/dist/web/dist/index.txt +1 -1
- package/dist/web/dist/jobs.html +1 -1
- package/dist/web/dist/jobs.txt +1 -1
- package/dist/web/dist/logs.html +1 -1
- package/dist/web/dist/logs.txt +1 -1
- package/dist/web/dist/matching/jobs/placeholder/talents.html +1 -1
- package/dist/web/dist/matching/jobs/placeholder/talents.txt +1 -1
- package/dist/web/dist/matching/portraits/placeholder/jobs.html +1 -1
- package/dist/web/dist/matching/portraits/placeholder/jobs.txt +1 -1
- package/dist/web/dist/parselog.html +1 -1
- package/dist/web/dist/parselog.txt +1 -1
- package/dist/web/dist/recordings.html +1 -1
- package/dist/web/dist/recordings.txt +1 -1
- package/dist/web/dist/snapshots/placeholder/agent-prompt.html +1 -1
- package/dist/web/dist/snapshots/placeholder/agent-prompt.txt +1 -1
- package/dist/web/dist/talents.html +1 -1
- package/dist/web/dist/talents.txt +1 -1
- package/package.json +5 -3
- package/scripts/postinstall-run.mjs +14 -0
- /package/dist/web/dist/_next/static/{WdJr3NhHjlu5xN0qOap1k → 3bVkK4pAgbbmp5r0ThyBL}/_buildManifest.js +0 -0
- /package/dist/web/dist/_next/static/{WdJr3NhHjlu5xN0qOap1k → 3bVkK4pAgbbmp5r0ThyBL}/_ssgManifest.js +0 -0
package/dist/commands/server.js
CHANGED
|
@@ -11,6 +11,61 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
|
11
11
|
const PID_FILE = () => path.join(resolveDataDir(), 'server.pid');
|
|
12
12
|
const LOG_FILE = () => PID_FILE().replace(/server\.pid$/, 'server.log');
|
|
13
13
|
const WATCHDOG_PID_FILE = () => PID_FILE().replace(/server\.pid$/, 'watchdog.pid');
|
|
14
|
+
/**
|
|
15
|
+
* FID-093:启动单飞锁。N 个 CLI 进程并发 auto-start(如多个 agent 同时跑
|
|
16
|
+
* extension 命令)时,旧逻辑各自 spawn watchdog——即便 watchdog 单例加固后
|
|
17
|
+
* 多实例会被拒,也会留下一地被误写的 pid 文件与误导性的"启动失败"输出。
|
|
18
|
+
* 持锁者完成 spawn + health 探测期间,其他进程改为等待并复用结果。
|
|
19
|
+
* 锁残留(持锁进程崩溃)按 mtime 判 stale 抢占。
|
|
20
|
+
*/
|
|
21
|
+
const START_LOCK_FILE = () => path.join(resolveDataDir(), 'server-start.lock');
|
|
22
|
+
const START_LOCK_STALE_MS = 90_000;
|
|
23
|
+
/** FID-093:导出供单测覆盖竞态/残留锁语义 */
|
|
24
|
+
export function acquireStartLock() {
|
|
25
|
+
const write = () => {
|
|
26
|
+
try {
|
|
27
|
+
const fd = fs.openSync(START_LOCK_FILE(), 'wx'); // 原子 O_CREAT|O_EXCL
|
|
28
|
+
try {
|
|
29
|
+
fs.writeFileSync(fd, String(process.pid));
|
|
30
|
+
}
|
|
31
|
+
finally {
|
|
32
|
+
fs.closeSync(fd);
|
|
33
|
+
}
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
if (write())
|
|
41
|
+
return 'acquired';
|
|
42
|
+
try {
|
|
43
|
+
if (Date.now() - fs.statSync(START_LOCK_FILE()).mtimeMs > START_LOCK_STALE_MS) {
|
|
44
|
+
fs.unlinkSync(START_LOCK_FILE());
|
|
45
|
+
if (write())
|
|
46
|
+
return 'stolen';
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
catch { /* 竞态:他人同时清理/占用,让出 */ }
|
|
50
|
+
return 'busy';
|
|
51
|
+
}
|
|
52
|
+
function releaseStartLock() {
|
|
53
|
+
try {
|
|
54
|
+
fs.unlinkSync(START_LOCK_FILE());
|
|
55
|
+
}
|
|
56
|
+
catch { /* ignore */ }
|
|
57
|
+
}
|
|
58
|
+
function watchdogAlive(pid) {
|
|
59
|
+
if (!pid)
|
|
60
|
+
return false;
|
|
61
|
+
try {
|
|
62
|
+
process.kill(pid, 0);
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
14
69
|
function tryReadPidFile(file) {
|
|
15
70
|
try {
|
|
16
71
|
const raw = fs.readFileSync(file, 'utf-8').trim();
|
|
@@ -88,8 +143,8 @@ export function findServerRoot() {
|
|
|
88
143
|
}
|
|
89
144
|
return null;
|
|
90
145
|
}
|
|
91
|
-
/** 探活:server /health 是否可访问(短超时,永不 throw) */
|
|
92
|
-
async function probeServerHealth(url, timeoutMs = 1500) {
|
|
146
|
+
/** 探活:server /health 是否可访问(短超时,永不 throw)。导出供 version.ts 重启编排复核 */
|
|
147
|
+
export async function probeServerHealth(url, timeoutMs = 1500) {
|
|
93
148
|
try {
|
|
94
149
|
const res = await fetch(`${url}/health`, { signal: AbortSignal.timeout(timeoutMs) });
|
|
95
150
|
return res.ok;
|
|
@@ -111,6 +166,42 @@ export async function runStart(opts) {
|
|
|
111
166
|
const url = `http://${host}:${port}`;
|
|
112
167
|
// 启动过程中的告警说明(会拼进最终返回 msg,保证用户看得见)
|
|
113
168
|
const notes = [];
|
|
169
|
+
// FID-093 单飞:另一进程正在启动时等待复用,不再并发起 watchdog
|
|
170
|
+
const lock = acquireStartLock();
|
|
171
|
+
if (lock === 'busy') {
|
|
172
|
+
const deadline = Date.now() + 35_000;
|
|
173
|
+
while (Date.now() < deadline) {
|
|
174
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
175
|
+
if (await probeServerHealth(url)) {
|
|
176
|
+
return {
|
|
177
|
+
started: true,
|
|
178
|
+
pid: null,
|
|
179
|
+
url,
|
|
180
|
+
msg: 'server 已由另一个 hunter-mate 进程启动完成(启动锁被持有,本次复用其结果)',
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return {
|
|
185
|
+
started: false,
|
|
186
|
+
pid: null,
|
|
187
|
+
url,
|
|
188
|
+
msg: '另一个 hunter-mate 进程持有 server 启动锁,且 35s 内 /health 未就绪。请执行 hunter-mate doctor 排查。',
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
if (lock === 'stolen') {
|
|
192
|
+
notes.push('⚠ 检测到残留的 server-start.lock(持有进程疑似崩溃),已按 stale 抢占继续启动');
|
|
193
|
+
}
|
|
194
|
+
try {
|
|
195
|
+
return await runStartLocked(opts, { url, port, host, notes });
|
|
196
|
+
}
|
|
197
|
+
finally {
|
|
198
|
+
releaseStartLock();
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
/** 单飞锁内的实际启动逻辑(锁外逻辑见 runStart) */
|
|
202
|
+
async function runStartLocked(opts, ctx) {
|
|
203
|
+
const { url, port, host, notes } = ctx;
|
|
204
|
+
const config = loadConfig();
|
|
114
205
|
const existing = tryReadPid();
|
|
115
206
|
if (existing) {
|
|
116
207
|
// pid 文件存在 ≠ server 存活。pid 可能被系统中其他进程复用
|
|
@@ -127,9 +218,42 @@ export async function runStart(opts) {
|
|
|
127
218
|
}
|
|
128
219
|
return { started: false, pid: existing, url, msg: `server already running (pid=${existing})` };
|
|
129
220
|
}
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
|
|
221
|
+
// FID-093:探活失败≠server 死了。机器抖动(构建/多实例抢 CPU)会让单次
|
|
222
|
+
// /health 超时,旧逻辑直接当 stale 清文件重拉 → 活 server 被孤儿化,
|
|
223
|
+
// 新实例 EADDRINUSE 崩溃循环(2026-09-10 23:13 实机复现)。先给 grace 窗口
|
|
224
|
+
// 复探:进程仍存活且 /health 恢复 → 按已运行处理,绝不双实例。
|
|
225
|
+
let recovered = false;
|
|
226
|
+
const graceDeadline = Date.now() + 10_000;
|
|
227
|
+
while (Date.now() < graceDeadline) {
|
|
228
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
229
|
+
const pidAlive = (() => {
|
|
230
|
+
try {
|
|
231
|
+
process.kill(existing, 0);
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
})();
|
|
238
|
+
if (!pidAlive)
|
|
239
|
+
break; // 真死了,走正常启动
|
|
240
|
+
if (await probeServerHealth(url)) {
|
|
241
|
+
recovered = true;
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (recovered) {
|
|
246
|
+
return {
|
|
247
|
+
started: false,
|
|
248
|
+
pid: existing,
|
|
249
|
+
url,
|
|
250
|
+
msg: `server already running (pid=${existing}; /health 曾短暂无响应,grace 窗口内恢复,未拉起新实例)`,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
// stale pid:进程号存活但 grace 后仍无响应(疑似僵死或被其他进程复用)。清理后继续启动,
|
|
254
|
+
// 不 kill —— 那个进程可能属于别的程序(误杀 foreign 进程红线);
|
|
255
|
+
// 若它其实占了 34570 端口,新实例会因 EADDRINUSE 显式崩溃退出,不静默。
|
|
256
|
+
notes.push(`⚠ 检测到 stale server.pid (pid=${existing}):该进程存活但 ${url}/health 在 10s grace 窗口内持续无响应,` +
|
|
133
257
|
`疑似 pid 被其他进程复用,已忽略并继续启动`);
|
|
134
258
|
cleanPidFile(PID_FILE());
|
|
135
259
|
}
|
|
@@ -211,8 +335,9 @@ export async function runStart(opts) {
|
|
|
211
335
|
windowsHide: true,
|
|
212
336
|
});
|
|
213
337
|
wd.unref();
|
|
214
|
-
|
|
215
|
-
|
|
338
|
+
// FID-093:不再由 CLI 覆写 watchdog.pid——watchdog 进程原子自注册(acquireWatchdogLock),
|
|
339
|
+
// 并发启动的败者若覆写会把胜者 pid 覆盖成将死进程,runStop 从此找不到活 watchdog。
|
|
340
|
+
// 就绪探测改为直接探 spawn 出的 watchdog 进程是否存活(比 pid 文件更直接)。
|
|
216
341
|
// 等待 server health 就绪。窗口给足:NestJS 全量路由映射 + TypeORM schema 同步
|
|
217
342
|
// 在慢机/Windows 上实测 10-15s,原 8s 会在 route mapping 阶段误报"未就绪"
|
|
218
343
|
// (server 实际 3 秒后就绪,watchdog 会保活,误报只会误导用户)。
|
|
@@ -229,8 +354,8 @@ export async function runStart(opts) {
|
|
|
229
354
|
}
|
|
230
355
|
}
|
|
231
356
|
catch { /* server 尚未监听,继续轮询 */ }
|
|
232
|
-
// watchdog 自身退出说明启动参数/入口有问题
|
|
233
|
-
if (!
|
|
357
|
+
// watchdog 自身退出说明启动参数/入口有问题(含单例锁被占,另一 watchdog 在保活)
|
|
358
|
+
if (!watchdogAlive(wd.pid ?? null))
|
|
234
359
|
break;
|
|
235
360
|
}
|
|
236
361
|
if (!ready) {
|
|
@@ -278,14 +403,15 @@ export async function runStop() {
|
|
|
278
403
|
const wdPid = tryReadPidFile(WATCHDOG_PID_FILE());
|
|
279
404
|
if (wdPid) {
|
|
280
405
|
if (!healthy) {
|
|
281
|
-
// server 不响应时不杀 watchdog pid(可能已被其他进程复用)
|
|
282
|
-
|
|
283
|
-
|
|
406
|
+
// server 不响应时不杀 watchdog pid(可能已被其他进程复用)——红线不动;
|
|
407
|
+
// FID-093:也不清 pid 文件——进程可能还活着,清了等于孤儿化注册表,
|
|
408
|
+
// 后续 auto-start 会再拉一个实例造成双 server(EADDRINUSE 崩溃循环)。
|
|
409
|
+
// 文件留在原地,runStart 的 grace 复探会正确处理。
|
|
284
410
|
return {
|
|
285
411
|
stopped: false,
|
|
286
412
|
pid: wdPid,
|
|
287
413
|
msg: `✗ server 无响应(${url}/health 不可达),watchdog pid=${wdPid} 未杀(疑似 stale pid,` +
|
|
288
|
-
`可能已被其他进程复用)
|
|
414
|
+
`可能已被其他进程复用)。pid 文件保留以便复探;如确认是僵死进程请手工 kill`,
|
|
289
415
|
};
|
|
290
416
|
}
|
|
291
417
|
const ok = killGracefullySync(wdPid);
|
|
@@ -308,13 +434,13 @@ export async function runStop() {
|
|
|
308
434
|
if (!pid)
|
|
309
435
|
return { stopped: false, pid: null, msg: 'no server.pid found or process dead' };
|
|
310
436
|
if (!healthy) {
|
|
311
|
-
// pid 存活但 server 不响应:不 kill(可能复用了别人的 pid)
|
|
312
|
-
|
|
437
|
+
// pid 存活但 server 不响应:不 kill(可能复用了别人的 pid)红线不动;
|
|
438
|
+
// FID-093:同样保留 pid 文件(进程可能还活着,清了会孤儿化注册表)
|
|
313
439
|
return {
|
|
314
440
|
stopped: false,
|
|
315
441
|
pid,
|
|
316
442
|
msg: `✗ pid=${pid} 存活但 server 无响应,未发送 kill 信号(疑似 stale pid,` +
|
|
317
|
-
`可能属于其他进程)
|
|
443
|
+
`可能属于其他进程)。pid 文件保留以便复探;如确认是僵死 server 请手工 kill`,
|
|
318
444
|
};
|
|
319
445
|
}
|
|
320
446
|
const ok = killGracefullySync(pid);
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** 定位 CLI 内置 extension dist(npm 包 assets 优先,monorepo 并列路径兜底) */
|
|
2
|
+
export declare function resolveBuiltinExtensionDist(): string | null;
|
|
3
|
+
export interface ExtensionArtifactsCompare {
|
|
4
|
+
/** 上游 = CLI 包内置产物目录;npm 包缺资产时为 null */
|
|
5
|
+
source: string | null;
|
|
6
|
+
/** 下游 = ~/.hunter-mate/extension */
|
|
7
|
+
target: string;
|
|
8
|
+
sourceChecksum: string | null;
|
|
9
|
+
targetChecksum: string | null;
|
|
10
|
+
sourceVersion: string | null;
|
|
11
|
+
targetVersion: string | null;
|
|
12
|
+
/** source 存在且 target 存在且 checksum 不同(上游升级了/下游被改过) */
|
|
13
|
+
stale: boolean;
|
|
14
|
+
}
|
|
15
|
+
export declare function compareExtensionArtifacts(targetDir?: string): ExtensionArtifactsCompare;
|
|
16
|
+
export interface ExtensionSyncResult {
|
|
17
|
+
synced: boolean;
|
|
18
|
+
reason: string;
|
|
19
|
+
copied: number;
|
|
20
|
+
removed: number;
|
|
21
|
+
fromVersion?: string;
|
|
22
|
+
toVersion?: string;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* 同步内置扩展产物到目标目录。
|
|
26
|
+
* - source 缺失(monorepo 未构建/npm 包缺资产)→ no-op
|
|
27
|
+
* - target 缺失 → 默认 no-op(首次安装走 hunter-mate install 引导);
|
|
28
|
+
* install 命令传 ensureTarget=true 创建
|
|
29
|
+
* - target 是软链(dev 模式)→ no-op,保护开发链
|
|
30
|
+
* - checksum 一致 → no-op;不一致 → 剪枝(删目标多余文件)+复制
|
|
31
|
+
*/
|
|
32
|
+
export declare function syncExtensionArtifacts(opts?: {
|
|
33
|
+
target?: string;
|
|
34
|
+
ensureTarget?: boolean;
|
|
35
|
+
}): ExtensionSyncResult;
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// extension-sync.ts — CLI 内置扩展产物 ↔ ~/.hunter-mate/extension 同步(Issue #23)
|
|
2
|
+
//
|
|
3
|
+
// 背景:npm 升级 CLI 后,~/.hunter-mate/extension 仍是旧版构建(update 一致性
|
|
4
|
+
// 检查只看"运行中扩展 vs 磁盘",二者同为旧版所以误判"无需 reload")。本模块以
|
|
5
|
+
// CLI 包内置产物(dist/assets/extension)为上游 SSOT,按目录 checksum 比对,
|
|
6
|
+
// 不一致则剪枝+复制,三处接线:
|
|
7
|
+
// 1) npm postinstall(升级 CLI 即自动同步,postinstall.ts)
|
|
8
|
+
// 2) update --apply / 一致性兜底(ensureRuntimeConsistency,先同步再广播 reload)
|
|
9
|
+
// 3) hunter-mate install(复用同一同步逻辑,替代旧的只增不剪 copy)
|
|
10
|
+
//
|
|
11
|
+
// 约束:永不抛异常(调用方含 postinstall,绝不能让 npm install 失败);
|
|
12
|
+
// 目标目录是软链(dev 开发模式)时跳过,不往里写。
|
|
13
|
+
import * as fs from 'fs';
|
|
14
|
+
import * as path from 'path';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
import { findBundledAssets } from './bundled-assets.js';
|
|
17
|
+
import { dirChecksum } from './utils/artifact-checksum.js';
|
|
18
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
/** 定位 CLI 内置 extension dist(npm 包 assets 优先,monorepo 并列路径兜底) */
|
|
20
|
+
export function resolveBuiltinExtensionDist() {
|
|
21
|
+
// 显式覆盖(测试夹具/应急指定),优先于一切自动探测;
|
|
22
|
+
// 设为空字符串 = 强制禁用自动探测(测试 no-bundled-assets 路径)
|
|
23
|
+
const override = process.env.HM_BUNDLED_EXTENSION_DIR;
|
|
24
|
+
if (override !== undefined)
|
|
25
|
+
return override && fs.existsSync(override) ? override : null;
|
|
26
|
+
// 优先 bundled-assets(npm 安装模式:dist/assets/extension 已存在)
|
|
27
|
+
const bundled = findBundledAssets();
|
|
28
|
+
if (bundled)
|
|
29
|
+
return bundled.extensionDir;
|
|
30
|
+
// fallback:monorepo 并列路径(老 hunter-mate 开发模式)
|
|
31
|
+
const candidates = [
|
|
32
|
+
path.resolve(__dirname, '../../extension/dist'), // src 布局: src -> packages/extension/dist
|
|
33
|
+
path.resolve(__dirname, '../../../extension/dist'), // dist 布局: dist/commands -> packages/extension/dist
|
|
34
|
+
path.resolve(__dirname, '../bundled-extension'), // bundled
|
|
35
|
+
];
|
|
36
|
+
for (const c of candidates)
|
|
37
|
+
if (fs.existsSync(c))
|
|
38
|
+
return c;
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
/** build-info.json(FID-092 起构建注入)优先,manifest.json version 兜底 */
|
|
42
|
+
function readArtifactVersion(dir) {
|
|
43
|
+
try {
|
|
44
|
+
const info = JSON.parse(fs.readFileSync(path.join(dir, 'build-info.json'), 'utf-8'));
|
|
45
|
+
if (info && typeof info.version === 'string' && info.version)
|
|
46
|
+
return info.version;
|
|
47
|
+
}
|
|
48
|
+
catch { /* 无 build-info */ }
|
|
49
|
+
try {
|
|
50
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(dir, 'manifest.json'), 'utf-8'));
|
|
51
|
+
if (manifest && typeof manifest.version === 'string' && manifest.version)
|
|
52
|
+
return manifest.version;
|
|
53
|
+
}
|
|
54
|
+
catch { /* 无 manifest */ }
|
|
55
|
+
return '0.0.0';
|
|
56
|
+
}
|
|
57
|
+
export function compareExtensionArtifacts(targetDir) {
|
|
58
|
+
const target = targetDir ?? defaultTargetDir();
|
|
59
|
+
const source = resolveBuiltinExtensionDist();
|
|
60
|
+
const sourceChecksum = source ? dirChecksum(source).checksum : null;
|
|
61
|
+
const targetChecksum = fs.existsSync(target) && !isSymlink(target) ? dirChecksum(target).checksum : null;
|
|
62
|
+
return {
|
|
63
|
+
source,
|
|
64
|
+
target,
|
|
65
|
+
sourceChecksum,
|
|
66
|
+
targetChecksum,
|
|
67
|
+
sourceVersion: source ? readArtifactVersion(source) : null,
|
|
68
|
+
targetVersion: targetChecksum ? readArtifactVersion(target) : null,
|
|
69
|
+
stale: !!source && !!sourceChecksum && !!targetChecksum && sourceChecksum !== targetChecksum,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* 同步内置扩展产物到目标目录。
|
|
74
|
+
* - source 缺失(monorepo 未构建/npm 包缺资产)→ no-op
|
|
75
|
+
* - target 缺失 → 默认 no-op(首次安装走 hunter-mate install 引导);
|
|
76
|
+
* install 命令传 ensureTarget=true 创建
|
|
77
|
+
* - target 是软链(dev 模式)→ no-op,保护开发链
|
|
78
|
+
* - checksum 一致 → no-op;不一致 → 剪枝(删目标多余文件)+复制
|
|
79
|
+
*/
|
|
80
|
+
export function syncExtensionArtifacts(opts = {}) {
|
|
81
|
+
const nil = (reason) => ({ synced: false, reason, copied: 0, removed: 0 });
|
|
82
|
+
try {
|
|
83
|
+
const target = opts.target ?? defaultTargetDir();
|
|
84
|
+
const source = resolveBuiltinExtensionDist();
|
|
85
|
+
if (!source)
|
|
86
|
+
return nil('no-bundled-assets');
|
|
87
|
+
if (isSymlink(target))
|
|
88
|
+
return nil('target-is-symlink');
|
|
89
|
+
if (!fs.existsSync(target)) {
|
|
90
|
+
if (!opts.ensureTarget)
|
|
91
|
+
return nil('target-missing');
|
|
92
|
+
fs.mkdirSync(target, { recursive: true });
|
|
93
|
+
}
|
|
94
|
+
const cmp = compareExtensionArtifacts(target);
|
|
95
|
+
if (!cmp.stale) {
|
|
96
|
+
return { synced: false, reason: 'identical', copied: 0, removed: 0, fromVersion: cmp.targetVersion ?? undefined, toVersion: cmp.sourceVersion ?? undefined };
|
|
97
|
+
}
|
|
98
|
+
// 剪枝:目标中上游不存在的文件/空目录删除(纯复制会留下改名后的尸体文件)
|
|
99
|
+
let removed = 0;
|
|
100
|
+
const prune = (dst, src) => {
|
|
101
|
+
if (!fs.existsSync(dst))
|
|
102
|
+
return;
|
|
103
|
+
for (const name of fs.readdirSync(dst)) {
|
|
104
|
+
const d = path.join(dst, name);
|
|
105
|
+
const s = path.join(src, name);
|
|
106
|
+
if (!fs.existsSync(s)) {
|
|
107
|
+
fs.rmSync(d, { recursive: true, force: true });
|
|
108
|
+
removed++;
|
|
109
|
+
}
|
|
110
|
+
else if (fs.statSync(d).isDirectory()) {
|
|
111
|
+
prune(d, s);
|
|
112
|
+
if (fs.existsSync(d) && fs.readdirSync(d).length === 0)
|
|
113
|
+
fs.rmdirSync(d);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
prune(target, source);
|
|
118
|
+
// 复制(覆盖)
|
|
119
|
+
let copied = 0;
|
|
120
|
+
const copy = (src, dst) => {
|
|
121
|
+
const st = fs.statSync(src);
|
|
122
|
+
if (st.isDirectory()) {
|
|
123
|
+
if (!fs.existsSync(dst))
|
|
124
|
+
fs.mkdirSync(dst, { recursive: true });
|
|
125
|
+
for (const f of fs.readdirSync(src))
|
|
126
|
+
copy(path.join(src, f), path.join(dst, f));
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
fs.copyFileSync(src, dst);
|
|
130
|
+
copied++;
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
copy(source, target);
|
|
134
|
+
return {
|
|
135
|
+
synced: true,
|
|
136
|
+
reason: 'stale',
|
|
137
|
+
copied,
|
|
138
|
+
removed,
|
|
139
|
+
fromVersion: cmp.targetVersion ?? undefined,
|
|
140
|
+
toVersion: cmp.sourceVersion ?? undefined,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
// 永不抛出(postinstall 场景绝不能让 npm install 失败)
|
|
145
|
+
return { synced: false, reason: `error: ${err.message}`, copied: 0, removed: 0 };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
function defaultTargetDir() {
|
|
149
|
+
const home = process.env.HUNTER_MATE_HOME || process.env.HOME || process.env.USERPROFILE || '.';
|
|
150
|
+
return path.join(home, '.hunter-mate', 'extension');
|
|
151
|
+
}
|
|
152
|
+
function isSymlink(dir) {
|
|
153
|
+
try {
|
|
154
|
+
return fs.lstatSync(dir).isSymbolicLink();
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -30,8 +30,11 @@ import { caseCommand } from './commands/case.js';
|
|
|
30
30
|
import { issueEnvCommand } from './commands/issue-env.js';
|
|
31
31
|
import { i18n } from './i18n/index.js';
|
|
32
32
|
import { initCliLogger, flushCliLogger, getCliLogger } from './logger.js';
|
|
33
|
+
import { installLocalProxyBypass } from './utils/local-http.js';
|
|
33
34
|
import packageJson from '../package.json' with { type: 'json' };
|
|
34
35
|
const CLI_VERSION = packageJson.version;
|
|
36
|
+
// Issue #24:本地 server 探测强制绕过 http_proxy(仅 localhost/127.0.0.1,远程不动)
|
|
37
|
+
installLocalProxyBypass();
|
|
35
38
|
// 未捕获异常兜底(fix #27):任何命令抛出的未处理 Promise 统一友好提示 + exit 1,
|
|
36
39
|
// 不再裸奔 stack 或以 exit code 0 静默结束。
|
|
37
40
|
process.on('unhandledRejection', (reason) => {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// postinstall.ts — npm install -g 后自动同步扩展产物到 ~/.hunter-mate/extension(Issue #23)
|
|
2
|
+
//
|
|
3
|
+
// 场景:用户 npm 升级 CLI(0.1.7 → 0.1.8+),~/.hunter-mate/extension 仍是旧版构建,
|
|
4
|
+
// update 一致性检查(运行中扩展 vs 磁盘)二者同为旧版,误判"无需 reload",
|
|
5
|
+
// 需手工 hunter-mate install 才刷新。postinstall 按 checksum 比对内置产物,
|
|
6
|
+
// 不一致即自动同步(等价自动跑 install 的产物同步部分)。
|
|
7
|
+
//
|
|
8
|
+
// 红线:本脚本在 npm install 事务内执行,任何情况不得非零退出(阻断用户安装)。
|
|
9
|
+
import * as fs from 'fs';
|
|
10
|
+
import { syncExtensionArtifacts } from './extension-sync.js';
|
|
11
|
+
function main() {
|
|
12
|
+
// 未初始化过(~/.hunter-mate/extension 不存在)→ 首次安装走 hunter-mate install 引导,不抢跑
|
|
13
|
+
const home = process.env.HUNTER_MATE_HOME || process.env.HOME || process.env.USERPROFILE;
|
|
14
|
+
if (!home)
|
|
15
|
+
return;
|
|
16
|
+
const target = `${home}/.hunter-mate/extension`;
|
|
17
|
+
if (!fs.existsSync(target))
|
|
18
|
+
return;
|
|
19
|
+
const r = syncExtensionArtifacts();
|
|
20
|
+
if (r.synced) {
|
|
21
|
+
process.stdout.write(`[hunter-mate] 扩展产物已自动同步: ${r.fromVersion ?? '?'} → ${r.toVersion ?? '?'}`
|
|
22
|
+
+ `(copied=${r.copied}, removed=${r.removed})\n`);
|
|
23
|
+
process.stdout.write('[hunter-mate] 若扩展正在运行,请执行 hunter-mate reload 或在 chrome://extensions 刷新\n');
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
main();
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// 永不阻断 npm install
|
|
31
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// utils/artifact-checksum.ts — 构建产物目录指纹(sha1 + 最新 mtime)
|
|
2
|
+
// 从 version.ts 抽出:postinstall/extension-sync 等轻量入口也要用,
|
|
3
|
+
// 不能反向依赖 version.ts(它拉 axios/nestjs 一整条重链)。
|
|
4
|
+
import * as fs from 'fs';
|
|
5
|
+
import * as path from 'path';
|
|
6
|
+
import { createHash } from 'crypto';
|
|
7
|
+
export function dirChecksum(dir) {
|
|
8
|
+
const hash = createHash('sha1');
|
|
9
|
+
let latest = 0;
|
|
10
|
+
function walk(d, rel = '') {
|
|
11
|
+
try {
|
|
12
|
+
const entries = fs.readdirSync(d, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
|
|
13
|
+
for (const entry of entries) {
|
|
14
|
+
const relPath = path.join(rel, entry.name);
|
|
15
|
+
const full = path.join(d, entry.name);
|
|
16
|
+
if (entry.isDirectory()) {
|
|
17
|
+
walk(full, relPath);
|
|
18
|
+
}
|
|
19
|
+
else if (entry.isFile() || entry.isSymbolicLink()) {
|
|
20
|
+
try {
|
|
21
|
+
const stat = fs.statSync(full);
|
|
22
|
+
if (stat.mtimeMs > latest)
|
|
23
|
+
latest = stat.mtimeMs;
|
|
24
|
+
hash.update(relPath + '\0');
|
|
25
|
+
const buf = fs.readFileSync(full);
|
|
26
|
+
hash.update(buf);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
/* ignore unreadable */
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
/* dir missing → empty */
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
walk(dir);
|
|
39
|
+
return { checksum: hash.digest('hex'), latestMtimeMs: latest };
|
|
40
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare function isLocalServerUrl(url: string | undefined): boolean;
|
|
2
|
+
/**
|
|
3
|
+
* 全局 axios 请求拦截器:调用方未显式指定 proxy 且目标是本地地址时强制 proxy:false。
|
|
4
|
+
* 在 CLI 入口(index.ts)注册一次,覆盖 status/doctor/extension 等所有探测路径;
|
|
5
|
+
* 显式传了 proxy(含已有的 proxy:false)的请求不覆盖,尊重调用方意图。
|
|
6
|
+
*/
|
|
7
|
+
export declare function installLocalProxyBypass(): void;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// utils/local-http.ts — 本地 server 探测的代理绕过(Issue #24 P1)
|
|
2
|
+
//
|
|
3
|
+
// 事故:用户 shell 带 http_proxy=http://127.0.0.1:1087 时,CLI 探测
|
|
4
|
+
// localhost:34570 被代理拦截(500)→ 误判 "server not running" → 自动启动
|
|
5
|
+
// 与 start 的 "already running" 形成死循环。Node/axios 默认读 http_proxy 环境变量,
|
|
6
|
+
// 只对 localhost/127.0.0.1/[::1] 禁用代理;远程 serverUrl(如 SaaS)保留代理行为。
|
|
7
|
+
import axios from 'axios';
|
|
8
|
+
const LOCAL_HOST_RE = /^(?:https?:)?\/\/(?:localhost|127\.0\.0\.1|\[::1\]|0\.0\.0\.0)(?::|\/|$)/i;
|
|
9
|
+
export function isLocalServerUrl(url) {
|
|
10
|
+
if (!url)
|
|
11
|
+
return false;
|
|
12
|
+
return LOCAL_HOST_RE.test(url);
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* 全局 axios 请求拦截器:调用方未显式指定 proxy 且目标是本地地址时强制 proxy:false。
|
|
16
|
+
* 在 CLI 入口(index.ts)注册一次,覆盖 status/doctor/extension 等所有探测路径;
|
|
17
|
+
* 显式传了 proxy(含已有的 proxy:false)的请求不覆盖,尊重调用方意图。
|
|
18
|
+
*/
|
|
19
|
+
export function installLocalProxyBypass() {
|
|
20
|
+
axios.interceptors.request.use((config) => {
|
|
21
|
+
if (config.proxy === undefined) {
|
|
22
|
+
const url = config.baseURL
|
|
23
|
+
? new URL(config.url ?? '', config.baseURL).toString()
|
|
24
|
+
: (config.url ?? '');
|
|
25
|
+
if (isLocalServerUrl(url))
|
|
26
|
+
config.proxy = false;
|
|
27
|
+
}
|
|
28
|
+
return config;
|
|
29
|
+
});
|
|
30
|
+
}
|