@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.
- package/bin/holin-cli.js +281 -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,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
|
-
|
|
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
|
|
157
|
-
console.log(" holin-cli auth logout
|
|
158
|
-
console.log(" holin-cli 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
|
}
|