@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.
Files changed (2) hide show
  1. package/bin/holin-cli.js +274 -16
  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/")) {
@@ -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
- log(`[proxy] ${proxyRes.statusCode} content-type=${proxyRes.headers["content-type"]}`);
240
- // Forward response headers (strip content-length for chunked SSE)
241
- const headers = { ...proxyRes.headers };
242
- delete headers["content-length"];
243
- delete headers["transfer-encoding"];
244
-
245
- res.writeHead(proxyRes.statusCode || 502, headers);
246
- proxyRes.pipe(res);
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
- // Cleanup on exit
398
- 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 {}
399
540
  try {
400
541
  if (existsSync(PID_FILE)) unlinkSync(PID_FILE);
401
542
  } catch {}
402
543
  process.exit(0);
403
- };
404
- process.on("SIGINT", cleanup);
405
- process.on("SIGTERM", cleanup);
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@holin-work/holin-cli",
3
- "version": "1.2.0",
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": {