@holin-work/holin-cli 1.2.1 → 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.
Files changed (2) hide show
  1. package/bin/holin-cli.js +238 -14
  2. 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
- log(`[proxy] ${proxyRes.statusCode} content-type=${proxyRes.headers["content-type"]} method=${mcpMethod}`);
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
- proxyRes.on("data", (c) => sseChunks.push(c));
256
- proxyRes.on("end", () => {
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
- res.writeHead(proxyRes.statusCode || 502, headers);
271
- res.end(jsonBody);
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
- // Cleanup on exit
432
- const cleanup = () => {
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
- process.on("SIGINT", cleanup);
439
- process.on("SIGTERM", cleanup);
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@holin-work/holin-cli",
3
- "version": "1.2.1",
3
+ "version": "1.3.0",
4
4
  "description": "Holin CLI — Accio plugin authorization tool for ICBU batch listing",
5
5
  "type": "module",
6
6
  "bin": {