@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.
- package/bin/holin-cli.js +283 -27
- 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
|
|
3
|
+
* holin-cli — Accio cli-login authorization + local MCP proxy
|
|
4
4
|
*
|
|
5
|
-
* Usage
|
|
6
|
-
* HOLIN_API_KEY=holin_xxx holin-cli auth login
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
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(
|
|
92
|
+
const res = await fetch(UPSTREAM_URL, {
|
|
48
93
|
method: "POST",
|
|
49
94
|
headers: {
|
|
50
95
|
"Content-Type": "application/json",
|
|
51
|
-
|
|
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
|
-
|
|
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
|
|
157
|
-
console.log(" holin-cli auth logout
|
|
158
|
-
console.log(" holin-cli 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
|
}
|