@holin-work/holin-cli 1.2.0 → 1.3.0
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/bin/holin-cli.js +274 -16
- package/package.json +1 -1
package/bin/holin-cli.js
CHANGED
|
@@ -11,18 +11,21 @@
|
|
|
11
11
|
* holin-cli proxy status # check proxy status
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import { existsSync, mkdirSync, writeFileSync, readFileSync, unlinkSync, openSync, appendFileSync } from "fs";
|
|
14
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync, unlinkSync, openSync, appendFileSync, statSync, renameSync } from "fs";
|
|
15
15
|
import { homedir } from "os";
|
|
16
16
|
import { join } from "path";
|
|
17
17
|
import http from "http";
|
|
18
18
|
import https from "https";
|
|
19
|
-
import { spawn } from "child_process";
|
|
19
|
+
import { spawn, execSync } from "child_process";
|
|
20
20
|
|
|
21
21
|
// ── Constants ────────────────────────────────────────────────────────────────
|
|
22
22
|
|
|
23
23
|
const UPSTREAM_URL = "https://api.holin.work/icbu/mcp";
|
|
24
24
|
const PROXY_HOST = "127.0.0.1";
|
|
25
25
|
const PROXY_PORT = 18787;
|
|
26
|
+
const IDLE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes idle → auto-exit
|
|
27
|
+
const HEALTH_CHECK_INTERVAL_MS = 60 * 1000; // check idle + credentials every 60s
|
|
28
|
+
const LOG_MAX_BYTES = 5 * 1024 * 1024; // 5MB log rotation
|
|
26
29
|
|
|
27
30
|
const CONFIG_DIR = join(
|
|
28
31
|
process.env.XDG_CONFIG_HOME || join(homedir(), ".config"),
|
|
@@ -79,12 +82,47 @@ function log(msg) {
|
|
|
79
82
|
const line = `[${new Date().toISOString()}] ${msg}\n`;
|
|
80
83
|
try {
|
|
81
84
|
ensureConfigDir();
|
|
85
|
+
// Rotate log if too large
|
|
86
|
+
if (existsSync(LOG_FILE)) {
|
|
87
|
+
const stat = statSync(LOG_FILE);
|
|
88
|
+
if (stat.size > LOG_MAX_BYTES) {
|
|
89
|
+
const rotated = LOG_FILE + ".1";
|
|
90
|
+
if (existsSync(rotated)) unlinkSync(rotated);
|
|
91
|
+
renameSync(LOG_FILE, rotated);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
82
94
|
appendFileSync(LOG_FILE, line);
|
|
83
95
|
} catch {
|
|
84
96
|
console.error(line.trim());
|
|
85
97
|
}
|
|
86
98
|
}
|
|
87
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Check if a port is in use, and if so, whether it's an old holin-cli proxy.
|
|
102
|
+
* Returns { inUse: boolean, isHolinProxy: boolean, pid: number|null }
|
|
103
|
+
*/
|
|
104
|
+
function checkPortInUse(port) {
|
|
105
|
+
try {
|
|
106
|
+
const output = execSync(`lsof -i :${port} -sTCP:LISTEN -P -n`, { encoding: "utf8" }).trim();
|
|
107
|
+
if (!output) return { inUse: false, isHolinProxy: false, pid: null };
|
|
108
|
+
// Parse PID from lsof output (second column, first data row)
|
|
109
|
+
const lines = output.split("\n").filter(l => l.includes("LISTEN"));
|
|
110
|
+
if (lines.length === 0) return { inUse: false, isHolinProxy: false, pid: null };
|
|
111
|
+
const parts = lines[0].split(/\s+/);
|
|
112
|
+
const pid = parseInt(parts[1], 10);
|
|
113
|
+
// Check if it's a holin-cli process
|
|
114
|
+
try {
|
|
115
|
+
const cmd = execSync(`ps -p ${pid} -o command=`, { encoding: "utf8" }).trim();
|
|
116
|
+
const isHolin = cmd.includes("holin-cli") && cmd.includes("proxy");
|
|
117
|
+
return { inUse: true, isHolinProxy: isHolin, pid };
|
|
118
|
+
} catch {
|
|
119
|
+
return { inUse: true, isHolinProxy: false, pid };
|
|
120
|
+
}
|
|
121
|
+
} catch {
|
|
122
|
+
return { inUse: false, isHolinProxy: false, pid: null };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
88
126
|
/**
|
|
89
127
|
* Verify API key by calling holin_ping via MCP.
|
|
90
128
|
*/
|
|
@@ -161,8 +199,12 @@ async function setCredentials(apiKey) {
|
|
|
161
199
|
|
|
162
200
|
// ── Proxy Server ─────────────────────────────────────────────────────────────
|
|
163
201
|
|
|
202
|
+
let lastRequestTime = Date.now();
|
|
203
|
+
|
|
164
204
|
function createProxyServer() {
|
|
165
205
|
const server = http.createServer((req, res) => {
|
|
206
|
+
lastRequestTime = Date.now();
|
|
207
|
+
const requestStart = Date.now();
|
|
166
208
|
// Collect request body for logging
|
|
167
209
|
const chunks = [];
|
|
168
210
|
req.on("data", (c) => chunks.push(c));
|
|
@@ -174,7 +216,7 @@ function createProxyServer() {
|
|
|
174
216
|
bodyPreview = JSON.stringify({ method: parsed.method, id: parsed.id, params: parsed.params ? Object.keys(parsed.params) : undefined });
|
|
175
217
|
} catch { /* not JSON */ }
|
|
176
218
|
|
|
177
|
-
log(`[proxy] ${req.method} ${req.url} body=${bodyPreview}`);
|
|
219
|
+
log(`[proxy] → ${req.method} ${req.url} body=${bodyPreview}`);
|
|
178
220
|
|
|
179
221
|
// Block OAuth discovery probes — return 401 so platform knows auth is required
|
|
180
222
|
if (req.url.startsWith("/.well-known/")) {
|
|
@@ -235,15 +277,64 @@ function createProxyServer() {
|
|
|
235
277
|
headers: forwardHeaders,
|
|
236
278
|
};
|
|
237
279
|
|
|
280
|
+
// Parse request body to determine MCP method
|
|
281
|
+
let mcpMethod = "";
|
|
282
|
+
try {
|
|
283
|
+
const parsed = JSON.parse(rawBody);
|
|
284
|
+
mcpMethod = parsed.method || "";
|
|
285
|
+
} catch { /* not JSON */ }
|
|
286
|
+
|
|
287
|
+
// Non-streaming methods should return JSON, not SSE
|
|
288
|
+
// Accio expects JSON for initialize and tools/list
|
|
289
|
+
const shouldConvertToJson = mcpMethod === "initialize" || mcpMethod === "tools/list";
|
|
290
|
+
|
|
238
291
|
const proxyReq = https.request(options, (proxyRes) => {
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
292
|
+
const elapsed = Date.now() - requestStart;
|
|
293
|
+
log(`[proxy] ← ${proxyRes.statusCode} ${elapsed}ms content-type=${proxyRes.headers["content-type"]} method=${mcpMethod}`);
|
|
294
|
+
|
|
295
|
+
if (shouldConvertToJson && proxyRes.headers["content-type"]?.includes("text/event-stream")) {
|
|
296
|
+
// Collect SSE response and convert to JSON
|
|
297
|
+
log(`[proxy] converting SSE→JSON for ${mcpMethod}`);
|
|
298
|
+
const sseChunks = [];
|
|
299
|
+
let ended = false;
|
|
300
|
+
const finish = () => {
|
|
301
|
+
if (ended) return;
|
|
302
|
+
ended = true;
|
|
303
|
+
const sseText = Buffer.concat(sseChunks).toString("utf8");
|
|
304
|
+
let jsonBody = sseText;
|
|
305
|
+
// Parse SSE: extract the last "data:" line
|
|
306
|
+
for (const line of sseText.split("\n")) {
|
|
307
|
+
const trimmed = line.trim();
|
|
308
|
+
if (trimmed.startsWith("data:")) {
|
|
309
|
+
jsonBody = trimmed.slice(5).trim();
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
const headers = { ...proxyRes.headers };
|
|
313
|
+
headers["content-type"] = "application/json";
|
|
314
|
+
headers["content-length"] = Buffer.byteLength(jsonBody);
|
|
315
|
+
delete headers["transfer-encoding"];
|
|
316
|
+
// Add mcp-session-id for stateful MCP clients (Accio expects this)
|
|
317
|
+
if (mcpMethod === "initialize") {
|
|
318
|
+
headers["mcp-session-id"] = `holin-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
319
|
+
}
|
|
320
|
+
log(`[proxy] converted SSE→JSON, ${jsonBody.length} bytes, session=${headers["mcp-session-id"] || "n/a"}`);
|
|
321
|
+
if (!res.headersSent) {
|
|
322
|
+
res.writeHead(proxyRes.statusCode || 502, headers);
|
|
323
|
+
res.end(jsonBody);
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
proxyRes.on("data", (c) => sseChunks.push(c));
|
|
327
|
+
proxyRes.on("end", finish);
|
|
328
|
+
// Safety timeout: SSE may keep connection open; force finish after 3s
|
|
329
|
+
setTimeout(finish, 3000);
|
|
330
|
+
} else {
|
|
331
|
+
// Stream SSE directly for tools/call and other methods
|
|
332
|
+
const headers = { ...proxyRes.headers };
|
|
333
|
+
delete headers["content-length"];
|
|
334
|
+
delete headers["transfer-encoding"];
|
|
335
|
+
res.writeHead(proxyRes.statusCode || 502, headers);
|
|
336
|
+
proxyRes.pipe(res);
|
|
337
|
+
}
|
|
247
338
|
});
|
|
248
339
|
|
|
249
340
|
proxyReq.on("error", (err) => {
|
|
@@ -368,6 +459,29 @@ function cmdProxyStart() {
|
|
|
368
459
|
return;
|
|
369
460
|
}
|
|
370
461
|
|
|
462
|
+
// Check if port is occupied by an old/stale holin-cli proxy
|
|
463
|
+
const portCheck = checkPortInUse(PROXY_PORT);
|
|
464
|
+
if (portCheck.inUse) {
|
|
465
|
+
if (portCheck.isHolinProxy) {
|
|
466
|
+
console.log(`[holin-cli] Port ${PROXY_PORT} occupied by stale holin-cli proxy (pid ${portCheck.pid}), killing it...`);
|
|
467
|
+
try {
|
|
468
|
+
process.kill(portCheck.pid, "SIGTERM");
|
|
469
|
+
// Wait for it to exit
|
|
470
|
+
let waited = 0;
|
|
471
|
+
while (checkPortInUse(PROXY_PORT).inUse && waited < 3000) {
|
|
472
|
+
execSync("sleep 0.2");
|
|
473
|
+
waited += 200;
|
|
474
|
+
}
|
|
475
|
+
} catch (err) {
|
|
476
|
+
console.error(`[holin-cli] Failed to kill stale proxy: ${err.message}`);
|
|
477
|
+
}
|
|
478
|
+
} else {
|
|
479
|
+
console.error(`[holin-cli] Error: Port ${PROXY_PORT} already in use by another process (pid ${portCheck.pid}).`);
|
|
480
|
+
console.error("[holin-cli] Please free the port or stop the other process.");
|
|
481
|
+
process.exit(1);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
371
485
|
const server = createProxyServer();
|
|
372
486
|
|
|
373
487
|
server.on("error", (err) => {
|
|
@@ -381,6 +495,7 @@ function cmdProxyStart() {
|
|
|
381
495
|
|
|
382
496
|
server.listen(PROXY_PORT, PROXY_HOST, () => {
|
|
383
497
|
writePidFile(process.pid);
|
|
498
|
+
lastRequestTime = Date.now();
|
|
384
499
|
const msg = `[holin-cli] Proxy listening on http://${PROXY_HOST}:${PROXY_PORT}/mcp (pid ${process.pid})`;
|
|
385
500
|
if (isDaemon) {
|
|
386
501
|
// Daemon mode: write to log file, don't print to stdout
|
|
@@ -390,19 +505,47 @@ function cmdProxyStart() {
|
|
|
390
505
|
console.log(msg);
|
|
391
506
|
console.log(`[holin-cli] Upstream: ${UPSTREAM_URL}`);
|
|
392
507
|
console.log(`[holin-cli] Health check: http://${PROXY_HOST}:${PROXY_PORT}/health`);
|
|
508
|
+
console.log(`[holin-cli] Idle timeout: ${IDLE_TIMEOUT_MS / 60000} minutes`);
|
|
393
509
|
console.log("[holin-cli] Press Ctrl+C to stop.");
|
|
394
510
|
}
|
|
395
511
|
});
|
|
396
512
|
|
|
397
|
-
//
|
|
398
|
-
const
|
|
513
|
+
// Health check: idle timeout + credentials file monitoring
|
|
514
|
+
const healthCheck = setInterval(() => {
|
|
515
|
+
const idleMs = Date.now() - lastRequestTime;
|
|
516
|
+
if (idleMs > IDLE_TIMEOUT_MS) {
|
|
517
|
+
log(`[proxy] Idle for ${Math.round(idleMs / 60000)} minutes, auto-exiting.`);
|
|
518
|
+
cleanupAndExit();
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
// Check if credentials file still exists (user may have logged out / uninstalled)
|
|
522
|
+
if (!existsSync(CREDENTIALS_FILE)) {
|
|
523
|
+
log("[proxy] Credentials file removed, auto-exiting.");
|
|
524
|
+
cleanupAndExit();
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
const creds = readCredentials();
|
|
528
|
+
if (!creds?.api_key) {
|
|
529
|
+
log("[proxy] Credentials cleared, auto-exiting.");
|
|
530
|
+
cleanupAndExit();
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
}, HEALTH_CHECK_INTERVAL_MS);
|
|
534
|
+
|
|
535
|
+
function cleanupAndExit() {
|
|
536
|
+
clearInterval(healthCheck);
|
|
537
|
+
try {
|
|
538
|
+
server.close();
|
|
539
|
+
} catch {}
|
|
399
540
|
try {
|
|
400
541
|
if (existsSync(PID_FILE)) unlinkSync(PID_FILE);
|
|
401
542
|
} catch {}
|
|
402
543
|
process.exit(0);
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Cleanup on exit
|
|
547
|
+
process.on("SIGINT", cleanupAndExit);
|
|
548
|
+
process.on("SIGTERM", cleanupAndExit);
|
|
406
549
|
}
|
|
407
550
|
|
|
408
551
|
function startProxyDaemon() {
|
|
@@ -474,6 +617,115 @@ function cmdProxyStatus() {
|
|
|
474
617
|
console.log(`[holin-cli] Log file: ${LOG_FILE}`);
|
|
475
618
|
}
|
|
476
619
|
|
|
620
|
+
async function cmdDoctor() {
|
|
621
|
+
const results = [];
|
|
622
|
+
const pass = (msg) => results.push({ ok: true, msg });
|
|
623
|
+
const fail = (msg, fix) => results.push({ ok: false, msg, fix });
|
|
624
|
+
const warn = (msg, fix) => results.push({ ok: null, msg, fix });
|
|
625
|
+
|
|
626
|
+
// 1. Credentials file
|
|
627
|
+
const creds = readCredentials();
|
|
628
|
+
if (!creds?.api_key) {
|
|
629
|
+
fail("Credentials file missing or empty", "Run: holin-cli auth login");
|
|
630
|
+
} else if (!creds.api_key.startsWith("holin_")) {
|
|
631
|
+
fail("API Key format invalid (must start with 'holin_')", "Re-run: holin-cli auth login");
|
|
632
|
+
} else {
|
|
633
|
+
pass(`Credentials file OK (${creds.customer_name || creds.customer_id || "unknown"} / ${creds.plan || "unknown plan"})`);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// 2. Proxy process
|
|
637
|
+
const running = isProxyRunning();
|
|
638
|
+
const pid = readPidFile();
|
|
639
|
+
if (running && pid) {
|
|
640
|
+
pass(`Proxy running (pid ${pid})`);
|
|
641
|
+
} else if (pid && !running) {
|
|
642
|
+
warn("Stale pid file found (process not running)", "Run: holin-cli proxy stop (cleans pid file)");
|
|
643
|
+
} else {
|
|
644
|
+
warn("Proxy not running", "Run: holin-cli auth login (auto-starts proxy)");
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// 3. Port
|
|
648
|
+
const portCheck = checkPortInUse(PROXY_PORT);
|
|
649
|
+
if (portCheck.inUse) {
|
|
650
|
+
if (portCheck.isHolinProxy) {
|
|
651
|
+
pass(`Port ${PROXY_PORT} in use by holin-cli proxy (pid ${portCheck.pid})`);
|
|
652
|
+
} else {
|
|
653
|
+
fail(`Port ${PROXY_PORT} occupied by another process (pid ${portCheck.pid})`, "Stop the other process or free the port");
|
|
654
|
+
}
|
|
655
|
+
} else if (running) {
|
|
656
|
+
warn(`Proxy running but port ${PROXY_PORT} not listening`, "Proxy may be in a bad state. Run: holin-cli proxy stop && holin-cli proxy start");
|
|
657
|
+
} else {
|
|
658
|
+
pass(`Port ${PROXY_PORT} is free`);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// 4. Upstream connectivity
|
|
662
|
+
if (creds?.api_key) {
|
|
663
|
+
try {
|
|
664
|
+
const result = await pingVerify(creds.api_key);
|
|
665
|
+
if (result?.authorized) {
|
|
666
|
+
pass(`Upstream API OK (authorized as ${result.customer_name || "unknown"})`);
|
|
667
|
+
} else {
|
|
668
|
+
fail("Upstream API reachable but API Key not authorized", "Check your API Key in the Holin merchant portal");
|
|
669
|
+
}
|
|
670
|
+
} catch (err) {
|
|
671
|
+
fail(`Upstream API unreachable: ${err.message}`, "Check network connection and https://api.holin.work status");
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// 5. MCP tools (via proxy if running)
|
|
676
|
+
if (running) {
|
|
677
|
+
try {
|
|
678
|
+
const res = await fetch(`http://${PROXY_HOST}:${PROXY_PORT}/mcp`, {
|
|
679
|
+
method: "POST",
|
|
680
|
+
headers: { "Content-Type": "application/json", Accept: "application/json, text/event-stream" },
|
|
681
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
|
|
682
|
+
signal: AbortSignal.timeout(5000),
|
|
683
|
+
});
|
|
684
|
+
if (res.ok) {
|
|
685
|
+
const data = await res.json();
|
|
686
|
+
const tools = data?.result?.tools || [];
|
|
687
|
+
pass(`MCP tools OK (${tools.length} tools: ${tools.map(t => t.name).join(", ")})`);
|
|
688
|
+
} else {
|
|
689
|
+
fail(`MCP tools/list returned ${res.status}`, "Check proxy log: " + LOG_FILE);
|
|
690
|
+
}
|
|
691
|
+
} catch (err) {
|
|
692
|
+
fail(`MCP tools/list failed: ${err.message}`, "Proxy may be unhealthy. Run: holin-cli proxy restart");
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// 6. Log file
|
|
697
|
+
try {
|
|
698
|
+
const stat = statSync(LOG_FILE);
|
|
699
|
+
const sizeKB = Math.round(stat.size / 1024);
|
|
700
|
+
if (stat.size > LOG_MAX_BYTES) {
|
|
701
|
+
warn(`Log file is ${sizeKB}KB (auto-rotates at ${LOG_MAX_BYTES / 1024 / 1024}MB)`, "Will be rotated on next write");
|
|
702
|
+
} else {
|
|
703
|
+
pass(`Log file OK (${sizeKB}KB)`);
|
|
704
|
+
}
|
|
705
|
+
} catch {
|
|
706
|
+
pass("Log file not created yet (no proxy activity)");
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// Print report
|
|
710
|
+
console.log("");
|
|
711
|
+
console.log("=== Holin CLI Doctor ===");
|
|
712
|
+
console.log("");
|
|
713
|
+
let hasFail = false;
|
|
714
|
+
for (const r of results) {
|
|
715
|
+
const icon = r.ok === true ? "✓" : r.ok === false ? "✗" : "⚠";
|
|
716
|
+
console.log(` ${icon} ${r.msg}`);
|
|
717
|
+
if (!r.ok && r.fix) console.log(` → ${r.fix}`);
|
|
718
|
+
if (r.ok === false) hasFail = true;
|
|
719
|
+
}
|
|
720
|
+
console.log("");
|
|
721
|
+
if (hasFail) {
|
|
722
|
+
console.log("Result: Issues found. Follow the fix suggestions above.");
|
|
723
|
+
process.exit(1);
|
|
724
|
+
} else {
|
|
725
|
+
console.log("Result: All checks passed.");
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
477
729
|
// ── Entrypoint ───────────────────────────────────────────────────────────────
|
|
478
730
|
|
|
479
731
|
const [, , cmd, sub] = process.argv;
|
|
@@ -493,6 +745,11 @@ if (cmd === "auth" && sub === "login") {
|
|
|
493
745
|
cmdProxyStop();
|
|
494
746
|
} else if (cmd === "proxy" && sub === "status") {
|
|
495
747
|
cmdProxyStatus();
|
|
748
|
+
} else if (cmd === "doctor") {
|
|
749
|
+
cmdDoctor().catch((err) => {
|
|
750
|
+
console.error(`[holin-cli] Doctor failed: ${err.message}`);
|
|
751
|
+
process.exit(1);
|
|
752
|
+
});
|
|
496
753
|
} else {
|
|
497
754
|
console.log("Usage:");
|
|
498
755
|
console.log(" holin-cli auth login — authorize with HOLIN_API_KEY env var + auto-start proxy");
|
|
@@ -501,5 +758,6 @@ if (cmd === "auth" && sub === "login") {
|
|
|
501
758
|
console.log(" holin-cli proxy start — start proxy in foreground");
|
|
502
759
|
console.log(" holin-cli proxy stop — stop background proxy");
|
|
503
760
|
console.log(" holin-cli proxy status — check proxy status");
|
|
761
|
+
console.log(" holin-cli doctor — diagnose all components");
|
|
504
762
|
process.exit(1);
|
|
505
763
|
}
|