@holin-work/holin-cli 1.0.0 → 1.1.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.
Files changed (2) hide show
  1. package/bin/holin-cli.js +283 -27
  2. package/package.json +1 -1
package/bin/holin-cli.js CHANGED
@@ -1,33 +1,45 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * holin-cli — Accio cli-login authorization tool
3
+ * holin-cli — Accio cli-login authorization + local MCP proxy
4
4
  *
5
- * Usage (invoked by Accio platform):
6
- * HOLIN_API_KEY=holin_xxx holin-cli auth login
7
- *
8
- * On success: writes credentials to ~/.config/holin-auth/credentials.json, exits 0
9
- * On failure: prints error to stderr, exits 1
5
+ * Usage:
6
+ * HOLIN_API_KEY=holin_xxx holin-cli auth login # authorize + auto-start proxy
7
+ * holin-cli auth logout # clear credentials + stop proxy
8
+ * holin-cli auth status # check auth status
9
+ * holin-cli proxy start # start proxy in foreground
10
+ * holin-cli proxy stop # stop background proxy
11
+ * holin-cli proxy status # check proxy status
10
12
  */
11
13
 
12
- import { existsSync, mkdirSync, writeFileSync, readFileSync } from "fs";
14
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, unlinkSync, openSync, appendFileSync } from "fs";
13
15
  import { homedir } from "os";
14
16
  import { join } from "path";
17
+ import http from "http";
18
+ import https from "https";
19
+ import { spawn } from "child_process";
15
20
 
16
21
  // ── Constants ────────────────────────────────────────────────────────────────
17
22
 
18
- const MCP_URL = "https://api.holin.work/icbu/mcp";
23
+ const UPSTREAM_URL = "https://api.holin.work/icbu/mcp";
24
+ const PROXY_HOST = "127.0.0.1";
25
+ const PROXY_PORT = 18787;
19
26
 
20
- const CREDENTIALS_FILE = join(
27
+ const CONFIG_DIR = join(
21
28
  process.env.XDG_CONFIG_HOME || join(homedir(), ".config"),
22
- "holin-auth",
23
- "credentials.json"
29
+ "holin-auth"
24
30
  );
31
+ const CREDENTIALS_FILE = join(CONFIG_DIR, "credentials.json");
32
+ const PID_FILE = join(CONFIG_DIR, "proxy.pid");
33
+ const LOG_FILE = join(CONFIG_DIR, "proxy.log");
25
34
 
26
35
  // ── Helpers ──────────────────────────────────────────────────────────────────
27
36
 
37
+ function ensureConfigDir() {
38
+ if (!existsSync(CONFIG_DIR)) mkdirSync(CONFIG_DIR, { recursive: true });
39
+ }
40
+
28
41
  function writeCredentials(data) {
29
- const dir = join(CREDENTIALS_FILE, "..");
30
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
42
+ ensureConfigDir();
31
43
  writeFileSync(CREDENTIALS_FILE, JSON.stringify(data, null, 2), "utf-8");
32
44
  }
33
45
 
@@ -39,16 +51,49 @@ function readCredentials() {
39
51
  }
40
52
  }
41
53
 
54
+ function writePidFile(pid) {
55
+ ensureConfigDir();
56
+ writeFileSync(PID_FILE, String(pid), "utf-8");
57
+ }
58
+
59
+ function readPidFile() {
60
+ try {
61
+ return parseInt(readFileSync(PID_FILE, "utf-8").trim(), 10);
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
67
+ function isProxyRunning() {
68
+ const pid = readPidFile();
69
+ if (!pid) return false;
70
+ try {
71
+ process.kill(pid, 0); // signal 0 just checks if process exists
72
+ return true;
73
+ } catch {
74
+ return false;
75
+ }
76
+ }
77
+
78
+ function log(msg) {
79
+ const line = `[${new Date().toISOString()}] ${msg}\n`;
80
+ try {
81
+ ensureConfigDir();
82
+ appendFileSync(LOG_FILE, line);
83
+ } catch {
84
+ console.error(line.trim());
85
+ }
86
+ }
87
+
42
88
  /**
43
89
  * Verify API key by calling holin_ping via MCP.
44
- * Returns parsed ping result or throws on failure.
45
90
  */
46
91
  async function pingVerify(apiKey) {
47
- const res = await fetch(MCP_URL, {
92
+ const res = await fetch(UPSTREAM_URL, {
48
93
  method: "POST",
49
94
  headers: {
50
95
  "Content-Type": "application/json",
51
- "Accept": "application/json, text/event-stream",
96
+ Accept: "application/json, text/event-stream",
52
97
  "X-API-Key": apiKey,
53
98
  },
54
99
  body: JSON.stringify({
@@ -60,12 +105,9 @@ async function pingVerify(apiKey) {
60
105
  signal: AbortSignal.timeout(10000),
61
106
  });
62
107
 
63
- if (!res.ok) {
64
- throw new Error(`MCP server responded ${res.status}`);
65
- }
108
+ if (!res.ok) throw new Error(`MCP server responded ${res.status}`);
66
109
 
67
110
  const raw = await res.text();
68
- // Parse SSE: find "data: {...}" line
69
111
  for (const line of raw.split("\n")) {
70
112
  const trimmed = line.trim();
71
113
  if (trimmed.startsWith("data:")) {
@@ -77,6 +119,95 @@ async function pingVerify(apiKey) {
77
119
  throw new Error("Unexpected response format from MCP server");
78
120
  }
79
121
 
122
+ // ── Proxy Server ─────────────────────────────────────────────────────────────
123
+
124
+ function createProxyServer() {
125
+ const server = http.createServer((req, res) => {
126
+ // Health check endpoint
127
+ if (req.method === "GET" && req.url === "/health") {
128
+ const creds = readCredentials();
129
+ const body = JSON.stringify({
130
+ status: "ok",
131
+ authenticated: !!(creds && creds.api_key),
132
+ customer: creds?.customer_name || null,
133
+ plan: creds?.plan || null,
134
+ upstream: UPSTREAM_URL,
135
+ pid: process.pid,
136
+ });
137
+ res.writeHead(200, {
138
+ "Content-Type": "application/json",
139
+ "Content-Length": Buffer.byteLength(body),
140
+ });
141
+ res.end(body);
142
+ return;
143
+ }
144
+
145
+ // Read credentials on every request (hot-reload support)
146
+ const creds = readCredentials();
147
+ if (!creds?.api_key) {
148
+ const body = JSON.stringify({ error: "not_authenticated", message: "Run 'holin-cli auth login' first." });
149
+ res.writeHead(401, {
150
+ "Content-Type": "application/json",
151
+ "Content-Length": Buffer.byteLength(body),
152
+ });
153
+ res.end(body);
154
+ return;
155
+ }
156
+
157
+ // Build upstream request
158
+ // MCP streamable-http has a single endpoint; forward directly to upstream path.
159
+ // Accio requests http://127.0.0.1:18787/mcp → upstream https://api.holin.work/icbu/mcp
160
+ const upstreamUrl = new URL(UPSTREAM_URL);
161
+ const options = {
162
+ hostname: upstreamUrl.hostname,
163
+ port: upstreamUrl.port || 443,
164
+ path: upstreamUrl.pathname,
165
+ method: req.method,
166
+ headers: {
167
+ ...req.headers,
168
+ host: upstreamUrl.hostname,
169
+ "X-API-Key": creds.api_key,
170
+ },
171
+ };
172
+
173
+ const proxyReq = https.request(options, (proxyRes) => {
174
+ // Forward response headers (strip content-length for chunked SSE)
175
+ const headers = { ...proxyRes.headers };
176
+ delete headers["content-length"];
177
+ delete headers["transfer-encoding"];
178
+
179
+ res.writeHead(proxyRes.statusCode || 502, headers);
180
+ proxyRes.pipe(res);
181
+ });
182
+
183
+ proxyReq.on("error", (err) => {
184
+ log(`[proxy] upstream error: ${err.message}`);
185
+ if (!res.headersSent) {
186
+ const body = JSON.stringify({ error: "upstream_error", message: err.message });
187
+ res.writeHead(502, {
188
+ "Content-Type": "application/json",
189
+ "Content-Length": Buffer.byteLength(body),
190
+ });
191
+ res.end(body);
192
+ } else {
193
+ res.end();
194
+ }
195
+ });
196
+
197
+ proxyReq.on("timeout", () => {
198
+ proxyReq.destroy();
199
+ if (!res.headersSent) {
200
+ res.writeHead(504, { "Content-Type": "application/json" });
201
+ res.end(JSON.stringify({ error: "gateway_timeout" }));
202
+ }
203
+ });
204
+
205
+ req.pipe(proxyReq);
206
+ });
207
+
208
+ return server;
209
+ }
210
+
80
211
  // ── Commands ─────────────────────────────────────────────────────────────────
81
212
 
82
213
  async function cmdAuthLogin() {
@@ -113,9 +244,16 @@ async function cmdAuthLogin() {
113
244
  };
114
245
 
115
246
  writeCredentials(creds);
116
- console.log(
117
- `[holin-cli] Connected: ${creds.customer_name || creds.customer_id} (${creds.plan})`
118
- );
247
+ console.log(`[holin-cli] Connected: ${creds.customer_name || creds.customer_id} (${creds.plan})`);
248
+
249
+ // Auto-start proxy in background (detached)
250
+ try {
251
+ startProxyDaemon();
252
+ console.log("[holin-cli] Local proxy started at http://127.0.0.1:18787/mcp");
253
+ } catch (err) {
254
+ console.error(`[holin-cli] Warning: Failed to start local proxy — ${err.message}`);
255
+ console.error("[holin-cli] You can start it manually with: holin-cli proxy start");
256
+ }
119
257
  }
120
258
 
121
259
  function cmdAuthLogout() {
@@ -125,6 +263,10 @@ function cmdAuthLogout() {
125
263
  } else {
126
264
  console.log("[holin-cli] No credentials found.");
127
265
  }
266
+ // Stop proxy if running
267
+ if (isProxyRunning()) {
268
+ cmdProxyStop();
269
+ }
128
270
  }
129
271
 
130
272
  function cmdAuthStatus() {
@@ -133,12 +275,117 @@ function cmdAuthStatus() {
133
275
  console.log("[holin-cli] Status: not connected.");
134
276
  process.exit(1);
135
277
  }
278
+ const proxyRunning = isProxyRunning();
136
279
  console.log(
137
280
  `[holin-cli] Status: connected as ${creds.customer_name || creds.customer_id} (${creds.plan})`
138
281
  );
282
+ console.log(`[holin-cli] Proxy: ${proxyRunning ? "running" : "stopped"} (http://127.0.0.1:18787/mcp)`);
283
+ }
284
+
285
+ function cmdProxyStart() {
286
+ const isDaemon = process.argv.includes("--daemon");
287
+
288
+ if (isProxyRunning()) {
289
+ console.log(`[holin-cli] Proxy already running (pid ${readPidFile()}).`);
290
+ if (!isDaemon) process.exit(0);
291
+ return;
292
+ }
293
+
294
+ const server = createProxyServer();
295
+
296
+ server.on("error", (err) => {
297
+ if (err.code === "EADDRINUSE") {
298
+ console.error(`[holin-cli] Error: Port ${PROXY_PORT} already in use.`);
299
+ process.exit(1);
300
+ }
301
+ console.error(`[holin-cli] Proxy error: ${err.message}`);
302
+ process.exit(1);
303
+ });
304
+
305
+ server.listen(PROXY_PORT, PROXY_HOST, () => {
306
+ writePidFile(process.pid);
307
+ const msg = `[holin-cli] Proxy listening on http://${PROXY_HOST}:${PROXY_PORT}/mcp (pid ${process.pid})`;
308
+ if (isDaemon) {
309
+ // Daemon mode: write to log file, don't print to stdout
310
+ ensureConfigDir();
311
+ appendFileSync(LOG_FILE, `[${new Date().toISOString()}] ${msg}\n`);
312
+ } else {
313
+ console.log(msg);
314
+ console.log(`[holin-cli] Upstream: ${UPSTREAM_URL}`);
315
+ console.log(`[holin-cli] Health check: http://${PROXY_HOST}:${PROXY_PORT}/health`);
316
+ console.log("[holin-cli] Press Ctrl+C to stop.");
317
+ }
318
+ });
319
+
320
+ // Cleanup on exit
321
+ const cleanup = () => {
322
+ try {
323
+ if (existsSync(PID_FILE)) unlinkSync(PID_FILE);
324
+ } catch {}
325
+ process.exit(0);
326
+ };
327
+ process.on("SIGINT", cleanup);
328
+ process.on("SIGTERM", cleanup);
329
+ }
330
+
331
+ function startProxyDaemon() {
332
+ if (isProxyRunning()) return;
333
+
334
+ ensureConfigDir();
335
+ const logFd = openSync(LOG_FILE, "a");
336
+
337
+ const child = spawn(process.execPath, [process.argv[1], "proxy", "start", "--daemon"], {
338
+ detached: true,
339
+ stdio: ["ignore", logFd, logFd],
340
+ });
341
+
342
+ child.unref();
343
+ // Give it a moment to start and write pid file
344
+ setTimeout(() => {
345
+ if (!isProxyRunning()) {
346
+ console.error("[holin-cli] Warning: Proxy failed to start. Check log:", LOG_FILE);
347
+ }
348
+ }, 1000);
349
+ }
350
+
351
+ function cmdProxyStop() {
352
+ const pid = readPidFile();
353
+ if (!pid) {
354
+ console.log("[holin-cli] Proxy is not running (no pid file).");
355
+ return;
356
+ }
357
+
358
+ try {
359
+ process.kill(pid, "SIGTERM");
360
+ console.log(`[holin-cli] Proxy stopped (pid ${pid}).`);
361
+ } catch (err) {
362
+ if (err.code === "ESRCH") {
363
+ console.log("[holin-cli] Proxy process not found (stale pid file), cleaning up.");
364
+ } else {
365
+ console.error(`[holin-cli] Error stopping proxy: ${err.message}`);
366
+ }
367
+ }
368
+
369
+ try {
370
+ if (existsSync(PID_FILE)) unlinkSync(PID_FILE);
371
+ } catch {}
372
+ }
373
+
374
+ function cmdProxyStatus() {
375
+ const running = isProxyRunning();
376
+ const pid = readPidFile();
377
+ const creds = readCredentials();
378
+
379
+ console.log(`[holin-cli] Proxy: ${running ? "running" : "stopped"}`);
380
+ if (running && pid) console.log(`[holin-cli] PID: ${pid}`);
381
+ console.log(`[holin-cli] Address: http://${PROXY_HOST}:${PROXY_PORT}/mcp`);
382
+ console.log(`[holin-cli] Upstream: ${UPSTREAM_URL}`);
383
+ console.log(`[holin-cli] Authenticated: ${creds?.api_key ? "yes" : "no"}`);
384
+ if (creds?.customer_name) console.log(`[holin-cli] Customer: ${creds.customer_name} (${creds.plan})`);
385
+ console.log(`[holin-cli] Log file: ${LOG_FILE}`);
139
386
  }
140
387
 
141
- // ── Entrypoint ────────────────────────────────────────────────────────────────
388
+ // ── Entrypoint ───────────────────────────────────────────────────────────────
142
389
 
143
390
  const [, , cmd, sub] = process.argv;
144
391
 
@@ -151,10 +398,19 @@ if (cmd === "auth" && sub === "login") {
151
398
  cmdAuthLogout();
152
399
  } else if (cmd === "auth" && sub === "status") {
153
400
  cmdAuthStatus();
401
+ } else if (cmd === "proxy" && sub === "start") {
402
+ cmdProxyStart();
403
+ } else if (cmd === "proxy" && sub === "stop") {
404
+ cmdProxyStop();
405
+ } else if (cmd === "proxy" && sub === "status") {
406
+ cmdProxyStatus();
154
407
  } else {
155
408
  console.log("Usage:");
156
- console.log(" holin-cli auth login — authorize with HOLIN_API_KEY env var");
157
- console.log(" holin-cli auth logout — clear stored credentials");
158
- console.log(" holin-cli auth status — check current auth status");
409
+ console.log(" holin-cli auth login — authorize with HOLIN_API_KEY env var + auto-start proxy");
410
+ console.log(" holin-cli auth logout — clear credentials + stop proxy");
411
+ console.log(" holin-cli auth status — check auth and proxy status");
412
+ console.log(" holin-cli proxy start — start proxy in foreground");
413
+ console.log(" holin-cli proxy stop — stop background proxy");
414
+ console.log(" holin-cli proxy status — check proxy status");
159
415
  process.exit(1);
160
416
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@holin-work/holin-cli",
3
- "version": "1.0.0",
3
+ "version": "1.1.1",
4
4
  "description": "Holin CLI — Accio plugin authorization tool for ICBU batch listing",
5
5
  "type": "module",
6
6
  "bin": {