@holin-work/holin-cli 1.2.1 → 1.3.1
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 +238 -14
- 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/")) {
|
|
@@ -247,13 +289,17 @@ function createProxyServer() {
|
|
|
247
289
|
const shouldConvertToJson = mcpMethod === "initialize" || mcpMethod === "tools/list";
|
|
248
290
|
|
|
249
291
|
const proxyReq = https.request(options, (proxyRes) => {
|
|
250
|
-
|
|
292
|
+
const elapsed = Date.now() - requestStart;
|
|
293
|
+
log(`[proxy] ← ${proxyRes.statusCode} ${elapsed}ms content-type=${proxyRes.headers["content-type"]} method=${mcpMethod}`);
|
|
251
294
|
|
|
252
295
|
if (shouldConvertToJson && proxyRes.headers["content-type"]?.includes("text/event-stream")) {
|
|
253
296
|
// Collect SSE response and convert to JSON
|
|
297
|
+
log(`[proxy] converting SSE→JSON for ${mcpMethod}`);
|
|
254
298
|
const sseChunks = [];
|
|
255
|
-
|
|
256
|
-
|
|
299
|
+
let ended = false;
|
|
300
|
+
const finish = () => {
|
|
301
|
+
if (ended) return;
|
|
302
|
+
ended = true;
|
|
257
303
|
const sseText = Buffer.concat(sseChunks).toString("utf8");
|
|
258
304
|
let jsonBody = sseText;
|
|
259
305
|
// Parse SSE: extract the last "data:" line
|
|
@@ -267,9 +313,20 @@ function createProxyServer() {
|
|
|
267
313
|
headers["content-type"] = "application/json";
|
|
268
314
|
headers["content-length"] = Buffer.byteLength(jsonBody);
|
|
269
315
|
delete headers["transfer-encoding"];
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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);
|
|
273
330
|
} else {
|
|
274
331
|
// Stream SSE directly for tools/call and other methods
|
|
275
332
|
const headers = { ...proxyRes.headers };
|
|
@@ -402,6 +459,29 @@ function cmdProxyStart() {
|
|
|
402
459
|
return;
|
|
403
460
|
}
|
|
404
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
|
+
|
|
405
485
|
const server = createProxyServer();
|
|
406
486
|
|
|
407
487
|
server.on("error", (err) => {
|
|
@@ -415,6 +495,7 @@ function cmdProxyStart() {
|
|
|
415
495
|
|
|
416
496
|
server.listen(PROXY_PORT, PROXY_HOST, () => {
|
|
417
497
|
writePidFile(process.pid);
|
|
498
|
+
lastRequestTime = Date.now();
|
|
418
499
|
const msg = `[holin-cli] Proxy listening on http://${PROXY_HOST}:${PROXY_PORT}/mcp (pid ${process.pid})`;
|
|
419
500
|
if (isDaemon) {
|
|
420
501
|
// Daemon mode: write to log file, don't print to stdout
|
|
@@ -424,19 +505,47 @@ function cmdProxyStart() {
|
|
|
424
505
|
console.log(msg);
|
|
425
506
|
console.log(`[holin-cli] Upstream: ${UPSTREAM_URL}`);
|
|
426
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`);
|
|
427
509
|
console.log("[holin-cli] Press Ctrl+C to stop.");
|
|
428
510
|
}
|
|
429
511
|
});
|
|
430
512
|
|
|
431
|
-
//
|
|
432
|
-
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 {}
|
|
433
540
|
try {
|
|
434
541
|
if (existsSync(PID_FILE)) unlinkSync(PID_FILE);
|
|
435
542
|
} catch {}
|
|
436
543
|
process.exit(0);
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Cleanup on exit
|
|
547
|
+
process.on("SIGINT", cleanupAndExit);
|
|
548
|
+
process.on("SIGTERM", cleanupAndExit);
|
|
440
549
|
}
|
|
441
550
|
|
|
442
551
|
function startProxyDaemon() {
|
|
@@ -508,6 +617,115 @@ function cmdProxyStatus() {
|
|
|
508
617
|
console.log(`[holin-cli] Log file: ${LOG_FILE}`);
|
|
509
618
|
}
|
|
510
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
|
+
|
|
511
729
|
// ── Entrypoint ───────────────────────────────────────────────────────────────
|
|
512
730
|
|
|
513
731
|
const [, , cmd, sub] = process.argv;
|
|
@@ -527,6 +745,11 @@ if (cmd === "auth" && sub === "login") {
|
|
|
527
745
|
cmdProxyStop();
|
|
528
746
|
} else if (cmd === "proxy" && sub === "status") {
|
|
529
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
|
+
});
|
|
530
753
|
} else {
|
|
531
754
|
console.log("Usage:");
|
|
532
755
|
console.log(" holin-cli auth login — authorize with HOLIN_API_KEY env var + auto-start proxy");
|
|
@@ -535,5 +758,6 @@ if (cmd === "auth" && sub === "login") {
|
|
|
535
758
|
console.log(" holin-cli proxy start — start proxy in foreground");
|
|
536
759
|
console.log(" holin-cli proxy stop — stop background proxy");
|
|
537
760
|
console.log(" holin-cli proxy status — check proxy status");
|
|
761
|
+
console.log(" holin-cli doctor — diagnose all components");
|
|
538
762
|
process.exit(1);
|
|
539
763
|
}
|