@holin-work/holin-cli 1.0.0 → 1.1.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 +281 -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,93 @@ 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
+ const upstreamUrl = new URL(UPSTREAM_URL);
159
+ const options = {
160
+ hostname: upstreamUrl.hostname,
161
+ port: upstreamUrl.port || 443,
162
+ path: upstreamUrl.pathname + (req.url || ""),
163
+ method: req.method,
164
+ headers: {
165
+ ...req.headers,
166
+ host: upstreamUrl.hostname,
167
+ "X-API-Key": creds.api_key,
168
+ },
169
+ };
170
+
171
+ const proxyReq = https.request(options, (proxyRes) => {
172
+ // Forward response headers (strip content-length for chunked SSE)
173
+ const headers = { ...proxyRes.headers };
174
+ delete headers["content-length"];
175
+ delete headers["transfer-encoding"];
176
+
177
+ res.writeHead(proxyRes.statusCode || 502, headers);
178
+ proxyRes.pipe(res);
179
+ });
180
+
181
+ proxyReq.on("error", (err) => {
182
+ log(`[proxy] upstream error: ${err.message}`);
183
+ if (!res.headersSent) {
184
+ const body = JSON.stringify({ error: "upstream_error", message: err.message });
185
+ res.writeHead(502, {
186
+ "Content-Type": "application/json",
187
+ "Content-Length": Buffer.byteLength(body),
188
+ });
189
+ res.end(body);
190
+ } else {
191
+ res.end();
192
+ }
193
+ });
194
+
195
+ proxyReq.on("timeout", () => {
196
+ proxyReq.destroy();
197
+ if (!res.headersSent) {
198
+ res.writeHead(504, { "Content-Type": "application/json" });
199
+ res.end(JSON.stringify({ error: "gateway_timeout" }));
200
+ }
201
+ });
202
+
203
+ req.pipe(proxyReq);
204
+ });
205
+
206
+ return server;
207
+ }
208
+
80
209
  // ── Commands ─────────────────────────────────────────────────────────────────
81
210
 
82
211
  async function cmdAuthLogin() {
@@ -113,9 +242,16 @@ async function cmdAuthLogin() {
113
242
  };
114
243
 
115
244
  writeCredentials(creds);
116
- console.log(
117
- `[holin-cli] Connected: ${creds.customer_name || creds.customer_id} (${creds.plan})`
118
- );
245
+ console.log(`[holin-cli] Connected: ${creds.customer_name || creds.customer_id} (${creds.plan})`);
246
+
247
+ // Auto-start proxy in background (detached)
248
+ try {
249
+ startProxyDaemon();
250
+ console.log("[holin-cli] Local proxy started at http://127.0.0.1:18787/mcp");
251
+ } catch (err) {
252
+ console.error(`[holin-cli] Warning: Failed to start local proxy — ${err.message}`);
253
+ console.error("[holin-cli] You can start it manually with: holin-cli proxy start");
254
+ }
119
255
  }
120
256
 
121
257
  function cmdAuthLogout() {
@@ -125,6 +261,10 @@ function cmdAuthLogout() {
125
261
  } else {
126
262
  console.log("[holin-cli] No credentials found.");
127
263
  }
264
+ // Stop proxy if running
265
+ if (isProxyRunning()) {
266
+ cmdProxyStop();
267
+ }
128
268
  }
129
269
 
130
270
  function cmdAuthStatus() {
@@ -133,12 +273,117 @@ function cmdAuthStatus() {
133
273
  console.log("[holin-cli] Status: not connected.");
134
274
  process.exit(1);
135
275
  }
276
+ const proxyRunning = isProxyRunning();
136
277
  console.log(
137
278
  `[holin-cli] Status: connected as ${creds.customer_name || creds.customer_id} (${creds.plan})`
138
279
  );
280
+ console.log(`[holin-cli] Proxy: ${proxyRunning ? "running" : "stopped"} (http://127.0.0.1:18787/mcp)`);
281
+ }
282
+
283
+ function cmdProxyStart() {
284
+ const isDaemon = process.argv.includes("--daemon");
285
+
286
+ if (isProxyRunning()) {
287
+ console.log(`[holin-cli] Proxy already running (pid ${readPidFile()}).`);
288
+ if (!isDaemon) process.exit(0);
289
+ return;
290
+ }
291
+
292
+ const server = createProxyServer();
293
+
294
+ server.on("error", (err) => {
295
+ if (err.code === "EADDRINUSE") {
296
+ console.error(`[holin-cli] Error: Port ${PROXY_PORT} already in use.`);
297
+ process.exit(1);
298
+ }
299
+ console.error(`[holin-cli] Proxy error: ${err.message}`);
300
+ process.exit(1);
301
+ });
302
+
303
+ server.listen(PROXY_PORT, PROXY_HOST, () => {
304
+ writePidFile(process.pid);
305
+ const msg = `[holin-cli] Proxy listening on http://${PROXY_HOST}:${PROXY_PORT}/mcp (pid ${process.pid})`;
306
+ if (isDaemon) {
307
+ // Daemon mode: write to log file, don't print to stdout
308
+ ensureConfigDir();
309
+ appendFileSync(LOG_FILE, `[${new Date().toISOString()}] ${msg}\n`);
310
+ } else {
311
+ console.log(msg);
312
+ console.log(`[holin-cli] Upstream: ${UPSTREAM_URL}`);
313
+ console.log(`[holin-cli] Health check: http://${PROXY_HOST}:${PROXY_PORT}/health`);
314
+ console.log("[holin-cli] Press Ctrl+C to stop.");
315
+ }
316
+ });
317
+
318
+ // Cleanup on exit
319
+ const cleanup = () => {
320
+ try {
321
+ if (existsSync(PID_FILE)) unlinkSync(PID_FILE);
322
+ } catch {}
323
+ process.exit(0);
324
+ };
325
+ process.on("SIGINT", cleanup);
326
+ process.on("SIGTERM", cleanup);
327
+ }
328
+
329
+ function startProxyDaemon() {
330
+ if (isProxyRunning()) return;
331
+
332
+ ensureConfigDir();
333
+ const logFd = openSync(LOG_FILE, "a");
334
+
335
+ const child = spawn(process.execPath, [process.argv[1], "proxy", "start", "--daemon"], {
336
+ detached: true,
337
+ stdio: ["ignore", logFd, logFd],
338
+ });
339
+
340
+ child.unref();
341
+ // Give it a moment to start and write pid file
342
+ setTimeout(() => {
343
+ if (!isProxyRunning()) {
344
+ console.error("[holin-cli] Warning: Proxy failed to start. Check log:", LOG_FILE);
345
+ }
346
+ }, 1000);
347
+ }
348
+
349
+ function cmdProxyStop() {
350
+ const pid = readPidFile();
351
+ if (!pid) {
352
+ console.log("[holin-cli] Proxy is not running (no pid file).");
353
+ return;
354
+ }
355
+
356
+ try {
357
+ process.kill(pid, "SIGTERM");
358
+ console.log(`[holin-cli] Proxy stopped (pid ${pid}).`);
359
+ } catch (err) {
360
+ if (err.code === "ESRCH") {
361
+ console.log("[holin-cli] Proxy process not found (stale pid file), cleaning up.");
362
+ } else {
363
+ console.error(`[holin-cli] Error stopping proxy: ${err.message}`);
364
+ }
365
+ }
366
+
367
+ try {
368
+ if (existsSync(PID_FILE)) unlinkSync(PID_FILE);
369
+ } catch {}
370
+ }
371
+
372
+ function cmdProxyStatus() {
373
+ const running = isProxyRunning();
374
+ const pid = readPidFile();
375
+ const creds = readCredentials();
376
+
377
+ console.log(`[holin-cli] Proxy: ${running ? "running" : "stopped"}`);
378
+ if (running && pid) console.log(`[holin-cli] PID: ${pid}`);
379
+ console.log(`[holin-cli] Address: http://${PROXY_HOST}:${PROXY_PORT}/mcp`);
380
+ console.log(`[holin-cli] Upstream: ${UPSTREAM_URL}`);
381
+ console.log(`[holin-cli] Authenticated: ${creds?.api_key ? "yes" : "no"}`);
382
+ if (creds?.customer_name) console.log(`[holin-cli] Customer: ${creds.customer_name} (${creds.plan})`);
383
+ console.log(`[holin-cli] Log file: ${LOG_FILE}`);
139
384
  }
140
385
 
141
- // ── Entrypoint ────────────────────────────────────────────────────────────────
386
+ // ── Entrypoint ───────────────────────────────────────────────────────────────
142
387
 
143
388
  const [, , cmd, sub] = process.argv;
144
389
 
@@ -151,10 +396,19 @@ if (cmd === "auth" && sub === "login") {
151
396
  cmdAuthLogout();
152
397
  } else if (cmd === "auth" && sub === "status") {
153
398
  cmdAuthStatus();
399
+ } else if (cmd === "proxy" && sub === "start") {
400
+ cmdProxyStart();
401
+ } else if (cmd === "proxy" && sub === "stop") {
402
+ cmdProxyStop();
403
+ } else if (cmd === "proxy" && sub === "status") {
404
+ cmdProxyStatus();
154
405
  } else {
155
406
  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");
407
+ console.log(" holin-cli auth login — authorize with HOLIN_API_KEY env var + auto-start proxy");
408
+ console.log(" holin-cli auth logout — clear credentials + stop proxy");
409
+ console.log(" holin-cli auth status — check auth and proxy status");
410
+ console.log(" holin-cli proxy start — start proxy in foreground");
411
+ console.log(" holin-cli proxy stop — stop background proxy");
412
+ console.log(" holin-cli proxy status — check proxy status");
159
413
  process.exit(1);
160
414
  }
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.0",
4
4
  "description": "Holin CLI — Accio plugin authorization tool for ICBU batch listing",
5
5
  "type": "module",
6
6
  "bin": {