@wolpertingerlabs/drawlatch 1.0.0-alpha.1 → 1.0.0-alpha.3

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/README.md CHANGED
@@ -1,4 +1,6 @@
1
- # drawlatch
1
+ # Drawlatch
2
+
3
+ > **Alpha Software:** This project is in alpha. Expect breaking changes between updates.
2
4
 
3
5
  A config-driven MCP (Model Context Protocol) proxy that lets Claude Code make authenticated HTTP requests to external APIs. Supports 22 pre-built API connections with endpoint allowlisting, per-caller access control, and real-time event ingestion — all configured through a single JSON file.
4
6
 
@@ -29,7 +31,7 @@ The crypto layer uses **Ed25519** signatures for authentication and **X25519 ECD
29
31
 
30
32
  ### Local Mode (In-Process Library)
31
33
 
32
- In local mode, there is no separate server, no network port, and no encryption. Your application imports drawlatch's core functions directly and calls them in-process:
34
+ In local mode, there is no separate server, no network port, and no encryption. Your application imports Drawlatch's core functions directly and calls them in-process:
33
35
 
34
36
  ```
35
37
  ┌──────────────────────────────────────────┐ Authenticated ┌──────────────┐
@@ -678,7 +680,7 @@ These additional protections apply when running the two-component remote archite
678
680
 
679
681
  ### Local Mode Caveat
680
682
 
681
- When using drawlatch as an in-process library (local mode), secrets are resolved from `process.env` on the same machine as the agent. The encryption and mutual authentication layers are not used. The security value in local mode comes from **structured access control** (endpoint allowlisting, per-caller route isolation) rather than cryptographic secret isolation.
683
+ When using Drawlatch as an in-process library (local mode), secrets are resolved from `process.env` on the same machine as the agent. The encryption and mutual authentication layers are not used. The security value in local mode comes from **structured access control** (endpoint allowlisting, per-caller route isolation) rather than cryptographic secret isolation.
682
684
 
683
685
  ## License
684
686
 
@@ -0,0 +1,713 @@
1
+ #!/usr/bin/env node
2
+
3
+ // ── Drawlatch CLI ─────────────────────────────────────────────────
4
+ // Entry point for the `drawlatch` command after global npm install.
5
+ // Provides daemon management for the remote server, key generation,
6
+ // log viewing, config introspection — all with zero extra dependencies.
7
+ // ───────────────────────────────────────────────────────────────────
8
+
9
+ import { parseArgs } from "node:util";
10
+ import { spawn } from "node:child_process";
11
+ import {
12
+ existsSync,
13
+ readFileSync,
14
+ writeFileSync,
15
+ unlinkSync,
16
+ mkdirSync,
17
+ openSync,
18
+ } from "node:fs";
19
+ import { stat } from "node:fs/promises";
20
+ import { join, resolve, dirname } from "node:path";
21
+ import { fileURLToPath } from "node:url";
22
+
23
+ // ── Paths & constants ─────────────────────────────────────────────
24
+ const __filename = fileURLToPath(import.meta.url);
25
+ const __dirname = dirname(__filename);
26
+ const PKG_ROOT = resolve(__dirname, "..");
27
+ const SERVER_ENTRY = join(PKG_ROOT, "dist/remote/server.js");
28
+ const GENERATE_KEYS_ENTRY = join(PKG_ROOT, "dist/cli/generate-keys.js");
29
+
30
+ // Import config helpers from compiled drawlatch code
31
+ const { getConfigDir, getEnvFilePath, loadRemoteConfig } = await import(
32
+ join(PKG_ROOT, "dist/shared/config.js")
33
+ );
34
+
35
+ const CONFIG_DIR = getConfigDir();
36
+ const ENV_FILE = getEnvFilePath();
37
+ const PID_FILE = join(CONFIG_DIR, "drawlatch.pid");
38
+ const LOG_DIR = join(CONFIG_DIR, "logs");
39
+ const LOG_FILE = join(LOG_DIR, "drawlatch.log");
40
+
41
+ // Read version from package.json
42
+ const pkgJson = JSON.parse(
43
+ readFileSync(join(PKG_ROOT, "package.json"), "utf-8"),
44
+ );
45
+ const VERSION = pkgJson.version;
46
+
47
+ // ── Argument parsing ──────────────────────────────────────────────
48
+ const rawArgs = process.argv.slice(2);
49
+ const subcommand =
50
+ rawArgs[0] && !rawArgs[0].startsWith("-") ? rawArgs.shift() : null;
51
+
52
+ let values, positionals;
53
+ try {
54
+ ({ values, positionals } = parseArgs({
55
+ args: rawArgs,
56
+ options: {
57
+ help: { type: "boolean", short: "h", default: false },
58
+ version: { type: "boolean", short: "v", default: false },
59
+ foreground: { type: "boolean", short: "f", default: false },
60
+ tunnel: { type: "boolean", short: "t", default: false },
61
+ port: { type: "string" },
62
+ host: { type: "string" },
63
+ lines: { type: "string", short: "n", default: "50" },
64
+ follow: { type: "boolean", default: true },
65
+ path: { type: "boolean", default: false },
66
+ },
67
+ strict: false,
68
+ allowPositionals: true,
69
+ }));
70
+ } catch (err) {
71
+ console.error(`Error: ${err.message}`);
72
+ process.exit(1);
73
+ }
74
+
75
+ // ── Dispatch ──────────────────────────────────────────────────────
76
+ if (values.version) {
77
+ console.log(VERSION);
78
+ process.exit(0);
79
+ }
80
+ if (values.help && !subcommand) {
81
+ printHelp();
82
+ process.exit(0);
83
+ }
84
+
85
+ switch (subcommand) {
86
+ case null:
87
+ await cmdDefault();
88
+ break;
89
+ case "start":
90
+ if (values.help) {
91
+ printStartHelp();
92
+ } else {
93
+ await cmdStart();
94
+ }
95
+ break;
96
+ case "stop":
97
+ if (values.help) {
98
+ printStopHelp();
99
+ } else {
100
+ await cmdStop();
101
+ }
102
+ break;
103
+ case "restart":
104
+ if (values.help) {
105
+ printRestartHelp();
106
+ } else {
107
+ await cmdRestart();
108
+ }
109
+ break;
110
+ case "status":
111
+ if (values.help) {
112
+ printStatusHelp();
113
+ } else {
114
+ await cmdStatus();
115
+ }
116
+ break;
117
+ case "logs":
118
+ if (values.help) {
119
+ printLogsHelp();
120
+ } else {
121
+ await cmdLogs();
122
+ }
123
+ break;
124
+ case "config":
125
+ if (values.help) {
126
+ printConfigHelp();
127
+ } else {
128
+ cmdConfig();
129
+ }
130
+ break;
131
+ case "generate-keys":
132
+ if (values.help) {
133
+ printGenerateKeysHelp();
134
+ } else {
135
+ await cmdGenerateKeys();
136
+ }
137
+ break;
138
+ case "help":
139
+ printHelp();
140
+ break;
141
+ default:
142
+ console.error(`Unknown command: ${subcommand}\n`);
143
+ printHelp();
144
+ process.exit(1);
145
+ }
146
+
147
+ // ── Commands ──────────────────────────────────────────────────────
148
+
149
+ async function cmdDefault() {
150
+ const pid = readPid();
151
+ if (pid) {
152
+ await cmdStatus();
153
+ } else {
154
+ console.log("Drawlatch remote server is not running.\n");
155
+ printHelp();
156
+ }
157
+ }
158
+
159
+ async function cmdStart() {
160
+ if (values.foreground) return cmdStartForeground();
161
+
162
+ ensureConfigDir();
163
+
164
+ const existingPid = readPid();
165
+ if (existingPid) {
166
+ console.log(`Remote server is already running (PID ${existingPid}).`);
167
+ console.log(` Use: drawlatch status`);
168
+ process.exit(0);
169
+ }
170
+
171
+ const config = loadRemoteConfig();
172
+ const port = values.port ? parseInt(values.port, 10) : config.port;
173
+ const host = values.host || config.host;
174
+
175
+ mkdirSync(LOG_DIR, { recursive: true });
176
+ const logFd = openSync(LOG_FILE, "a");
177
+
178
+ const child = spawn(process.execPath, [SERVER_ENTRY], {
179
+ detached: true,
180
+ stdio: ["ignore", logFd, logFd],
181
+ env: {
182
+ ...process.env,
183
+ NODE_ENV: "production",
184
+ ...(values.port ? { DRAWLATCH_PORT: String(port) } : {}),
185
+ ...(values.host ? { DRAWLATCH_HOST: host } : {}),
186
+ ...(values.tunnel ? { DRAWLATCH_TUNNEL: "1" } : {}),
187
+ },
188
+ cwd: PKG_ROOT,
189
+ });
190
+
191
+ writeFileSync(PID_FILE, String(child.pid) + "\n");
192
+ child.unref();
193
+
194
+ console.log(`Starting drawlatch remote server on ${host}:${port}...`);
195
+ const healthy = await waitForHealth(host, port, 5000);
196
+
197
+ if (healthy) {
198
+ console.log(`\nRemote server is running (PID ${child.pid}).`);
199
+ console.log(` Listening: ${host}:${port}`);
200
+ if (values.tunnel) {
201
+ // The tunnel starts asynchronously after the server is healthy —
202
+ // poll the health endpoint until the tunnel URL appears (up to 20s).
203
+ console.log(` Tunnel: waiting for cloudflared...`);
204
+ const tunnelUrl = await waitForTunnelUrl(host, port, 20000);
205
+ if (tunnelUrl) {
206
+ console.log(` Tunnel: ${tunnelUrl}`);
207
+ console.log(` Webhooks: ${tunnelUrl}/webhooks/<path>`);
208
+ } else {
209
+ console.log(` Tunnel: not available (check logs: drawlatch logs)`);
210
+ }
211
+ }
212
+ console.log(` Logs: drawlatch logs`);
213
+ } else {
214
+ console.log(
215
+ `\nServer started (PID ${child.pid}) but health check did not pass.`,
216
+ );
217
+ console.log(` Check logs: drawlatch logs`);
218
+ await diagnoseStartFailure();
219
+ }
220
+ }
221
+
222
+ async function cmdStartForeground() {
223
+ process.env.NODE_ENV = process.env.NODE_ENV || "production";
224
+ if (values.port) process.env.DRAWLATCH_PORT = values.port;
225
+ if (values.host) process.env.DRAWLATCH_HOST = values.host;
226
+ if (values.tunnel) process.env.DRAWLATCH_TUNNEL = "1";
227
+
228
+ ensureConfigDir();
229
+
230
+ const { main } = await import(SERVER_ENTRY);
231
+ main();
232
+ }
233
+
234
+ async function cmdStop() {
235
+ const pid = readPid();
236
+ if (!pid) {
237
+ console.log("Remote server is not running.");
238
+ process.exit(0);
239
+ }
240
+
241
+ console.log(`Stopping remote server (PID ${pid})...`);
242
+ try {
243
+ process.kill(pid, "SIGTERM");
244
+ } catch {
245
+ // Process already gone
246
+ cleanPidFile();
247
+ console.log("Server stopped.");
248
+ return;
249
+ }
250
+
251
+ const stopped = await waitForExit(pid, 5000);
252
+ if (!stopped) {
253
+ console.log("Server did not stop gracefully, sending SIGKILL...");
254
+ try {
255
+ process.kill(pid, "SIGKILL");
256
+ } catch {
257
+ // Already gone
258
+ }
259
+ }
260
+
261
+ cleanPidFile();
262
+ console.log("Server stopped.");
263
+ }
264
+
265
+ async function cmdRestart() {
266
+ const pid = readPid();
267
+ if (pid) {
268
+ // If the previous server had an active tunnel, carry the flag forward
269
+ // so the restarted server also starts a tunnel (unless --tunnel is
270
+ // already set or the user explicitly omitted it).
271
+ if (!values.tunnel) {
272
+ const config = loadRemoteConfig();
273
+ const prevHealth = await healthCheckFull(config.host, config.port);
274
+ if (prevHealth?.tunnelUrl) {
275
+ console.log("Previous server had an active tunnel — re-enabling --tunnel.");
276
+ values.tunnel = true;
277
+ }
278
+ }
279
+ await cmdStop();
280
+ }
281
+ await cmdStart();
282
+ }
283
+
284
+ async function cmdStatus() {
285
+ const pid = readPid();
286
+ if (!pid) {
287
+ console.log("Drawlatch remote server is not running.");
288
+ process.exit(0);
289
+ }
290
+
291
+ const config = loadRemoteConfig();
292
+ const port = config.port;
293
+ const host = config.host;
294
+
295
+ let uptime = "unknown";
296
+ try {
297
+ const pidStat = await stat(PID_FILE);
298
+ uptime = formatUptime(Date.now() - pidStat.mtimeMs);
299
+ } catch {
300
+ // Can't stat PID file
301
+ }
302
+
303
+ const healthData = await healthCheckFull(host, port);
304
+
305
+ console.log("Drawlatch remote server is running.");
306
+ console.log(` PID: ${pid}`);
307
+ console.log(` Listening: ${host}:${port}`);
308
+ console.log(` Uptime: ${uptime}`);
309
+ console.log(
310
+ ` Health: ${healthData ? "healthy" : "unhealthy (not responding)"}`,
311
+ );
312
+ if (healthData) {
313
+ console.log(` Active sessions: ${healthData.activeSessions}`);
314
+ if (healthData.tunnelUrl) {
315
+ console.log(` Tunnel: ${healthData.tunnelUrl}`);
316
+ }
317
+ }
318
+ }
319
+
320
+ async function cmdLogs() {
321
+ if (!existsSync(LOG_FILE)) {
322
+ console.log("No log file found. Start the server first:");
323
+ console.log(" drawlatch start");
324
+ process.exit(0);
325
+ }
326
+
327
+ const lines = parseInt(values.lines, 10) || 50;
328
+ const follow = values.follow;
329
+
330
+ const tailArgs = follow
331
+ ? ["-n", String(lines), "-f", LOG_FILE]
332
+ : ["-n", String(lines), LOG_FILE];
333
+
334
+ const tail = spawn("tail", tailArgs, { stdio: "inherit" });
335
+
336
+ tail.on("error", () => {
337
+ // Fallback: read last N lines with Node.js if tail is not available
338
+ try {
339
+ const content = readFileSync(LOG_FILE, "utf-8");
340
+ const allLines = content.split("\n");
341
+ const lastLines = allLines.slice(-lines).join("\n");
342
+ console.log(lastLines);
343
+ if (follow) {
344
+ console.log(
345
+ "\n(Live following not available \u2014 'tail' command not found)",
346
+ );
347
+ }
348
+ } catch (err) {
349
+ console.error(`Error reading log file: ${err.message}`);
350
+ process.exit(1);
351
+ }
352
+ });
353
+
354
+ // Forward SIGINT to cleanly exit
355
+ process.on("SIGINT", () => {
356
+ tail.kill();
357
+ process.exit(0);
358
+ });
359
+
360
+ // Wait for tail to exit (when using --no-follow)
361
+ await new Promise((res) => tail.on("close", res));
362
+ }
363
+
364
+ function cmdConfig() {
365
+ ensureConfigDir();
366
+
367
+ if (values.path) {
368
+ console.log(join(CONFIG_DIR, "remote.config.json"));
369
+ return;
370
+ }
371
+
372
+ const config = loadRemoteConfig();
373
+
374
+ console.log(`\nDrawlatch Configuration`);
375
+ console.log(`=======================`);
376
+
377
+ console.log(`\nRemote Server:`);
378
+ console.log(` Host: ${config.host}`);
379
+ console.log(` Port: ${config.port}`);
380
+ console.log(` Rate limit: ${config.rateLimitPerMinute} req/min`);
381
+ console.log(` Local keys dir: ${config.localKeysDir}`);
382
+
383
+ const callerEntries = Object.entries(config.callers || {});
384
+ console.log(` Callers: ${callerEntries.length}`);
385
+ for (const [alias, caller] of callerEntries) {
386
+ console.log(
387
+ ` ${alias}: ${caller.connections ? caller.connections.length : 0} connection(s)`,
388
+ );
389
+ }
390
+
391
+ console.log(
392
+ ` Connectors: ${config.connectors ? config.connectors.length : 0}`,
393
+ );
394
+
395
+ console.log(`\nPaths:`);
396
+ console.log(` Config dir: ${CONFIG_DIR}`);
397
+ console.log(` Env file: ${ENV_FILE}`);
398
+ console.log(` Remote cfg: ${join(CONFIG_DIR, "remote.config.json")}`);
399
+ console.log(` Proxy cfg: ${join(CONFIG_DIR, "proxy.config.json")}`);
400
+ console.log(` Logs: ${LOG_FILE}`);
401
+ console.log(` PID file: ${PID_FILE}`);
402
+ console.log();
403
+ }
404
+
405
+ async function cmdGenerateKeys() {
406
+ // Forward all remaining positional args to the generate-keys script
407
+ const child = spawn(process.execPath, [GENERATE_KEYS_ENTRY, ...positionals], {
408
+ stdio: "inherit",
409
+ cwd: PKG_ROOT,
410
+ });
411
+
412
+ await new Promise((res) => child.on("close", res));
413
+ process.exit(child.exitCode ?? 0);
414
+ }
415
+
416
+ // ── PID utilities ─────────────────────────────────────────────────
417
+
418
+ function isProcessAlive(pid) {
419
+ try {
420
+ process.kill(pid, 0);
421
+ return true;
422
+ } catch (e) {
423
+ return e.code === "EPERM"; // EPERM = alive but owned by another user
424
+ }
425
+ }
426
+
427
+ function readPid() {
428
+ if (!existsSync(PID_FILE)) return null;
429
+ const raw = readFileSync(PID_FILE, "utf-8").trim();
430
+ const pid = parseInt(raw, 10);
431
+ if (isNaN(pid)) {
432
+ cleanPidFile();
433
+ return null;
434
+ }
435
+ if (!isProcessAlive(pid)) {
436
+ cleanPidFile();
437
+ return null;
438
+ }
439
+ return pid;
440
+ }
441
+
442
+ function cleanPidFile() {
443
+ try {
444
+ unlinkSync(PID_FILE);
445
+ } catch {
446
+ // Already gone
447
+ }
448
+ }
449
+
450
+ // ── Health check utilities ────────────────────────────────────────
451
+
452
+ async function healthCheck(host, port) {
453
+ try {
454
+ const controller = new AbortController();
455
+ const timeout = setTimeout(() => controller.abort(), 3000);
456
+ const res = await fetch(`http://${host}:${port}/health`, {
457
+ signal: controller.signal,
458
+ });
459
+ clearTimeout(timeout);
460
+ return res.ok;
461
+ } catch {
462
+ return false;
463
+ }
464
+ }
465
+
466
+ async function healthCheckFull(host, port) {
467
+ try {
468
+ const controller = new AbortController();
469
+ const timeout = setTimeout(() => controller.abort(), 3000);
470
+ const res = await fetch(`http://${host}:${port}/health`, {
471
+ signal: controller.signal,
472
+ });
473
+ clearTimeout(timeout);
474
+ if (!res.ok) return null;
475
+ return await res.json();
476
+ } catch {
477
+ return null;
478
+ }
479
+ }
480
+
481
+ async function waitForTunnelUrl(host, port, timeoutMs) {
482
+ const start = Date.now();
483
+ while (Date.now() - start < timeoutMs) {
484
+ const data = await healthCheckFull(host, port);
485
+ if (data?.tunnelUrl) return data.tunnelUrl;
486
+ await sleep(500);
487
+ }
488
+ return null;
489
+ }
490
+
491
+ async function waitForHealth(host, port, timeoutMs) {
492
+ const start = Date.now();
493
+ while (Date.now() - start < timeoutMs) {
494
+ if (await healthCheck(host, port)) return true;
495
+ await sleep(500);
496
+ }
497
+ return false;
498
+ }
499
+
500
+ async function waitForExit(pid, timeoutMs) {
501
+ const start = Date.now();
502
+ while (Date.now() - start < timeoutMs) {
503
+ if (!isProcessAlive(pid)) return true;
504
+ await sleep(250);
505
+ }
506
+ return false;
507
+ }
508
+
509
+ // ── Config utilities ──────────────────────────────────────────────
510
+
511
+ function ensureConfigDir() {
512
+ mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
513
+ }
514
+
515
+ // ── Diagnostic utilities ──────────────────────────────────────────
516
+
517
+ async function diagnoseStartFailure() {
518
+ if (!existsSync(LOG_FILE)) return;
519
+ try {
520
+ const content = readFileSync(LOG_FILE, "utf-8");
521
+ const lines = content.split("\n").slice(-20);
522
+ const eaddrinuse = lines.find((l) => l.includes("EADDRINUSE"));
523
+ const eacces = lines.find((l) => l.includes("EACCES"));
524
+ if (eaddrinuse) {
525
+ console.log("\n Error: Port is already in use.");
526
+ console.log(" Another process may be using the same port.");
527
+ } else if (eacces) {
528
+ console.log("\n Error: Permission denied.");
529
+ console.log(" Try using a port >= 1024.");
530
+ }
531
+ } catch {
532
+ // Best effort
533
+ }
534
+ }
535
+
536
+ // ── Output / formatting ──────────────────────────────────────────
537
+
538
+ function formatUptime(ms) {
539
+ const s = Math.floor(ms / 1000);
540
+ const d = Math.floor(s / 86400);
541
+ const h = Math.floor((s % 86400) / 3600);
542
+ const m = Math.floor((s % 3600) / 60);
543
+ if (d > 0) return `${d}d ${h}h ${m}m`;
544
+ if (h > 0) return `${h}h ${m}m`;
545
+ return `${m}m`;
546
+ }
547
+
548
+ function sleep(ms) {
549
+ return new Promise((r) => setTimeout(r, ms));
550
+ }
551
+
552
+ // ── Help text ─────────────────────────────────────────────────────
553
+
554
+ function printHelp() {
555
+ console.log(`
556
+ drawlatch v${VERSION}
557
+
558
+ Usage: drawlatch [command] [options]
559
+
560
+ Commands:
561
+ start Start the remote server (background by default)
562
+ stop Stop the background remote server
563
+ restart Restart the background remote server
564
+ status Show server status (PID, port, uptime, health, sessions)
565
+ logs View and follow remote server logs
566
+ config Show effective configuration
567
+ generate-keys Generate Ed25519 + X25519 keypairs
568
+
569
+ Options:
570
+ -h, --help Show this help message
571
+ -v, --version Show version number
572
+
573
+ Running 'drawlatch' with no arguments shows status (if running) or this help.
574
+
575
+ Examples:
576
+ drawlatch Show status or help
577
+ drawlatch start Start remote server in background
578
+ drawlatch start -f Start remote server in foreground
579
+ drawlatch start -f --tunnel Start with a public tunnel for webhooks
580
+ drawlatch start --port 8080 Start on a custom port
581
+ drawlatch status Check if server is running
582
+ drawlatch logs -n 100 View last 100 log lines
583
+ drawlatch generate-keys remote Generate remote server keypair
584
+ drawlatch generate-keys local mybot Generate local keypair for alias "mybot"
585
+ `);
586
+ }
587
+
588
+ function printStartHelp() {
589
+ console.log(`
590
+ drawlatch start
591
+
592
+ Start the drawlatch remote server.
593
+
594
+ Usage: drawlatch start [options]
595
+
596
+ Options:
597
+ -f, --foreground Run in foreground (default when no command given)
598
+ -t, --tunnel Start a Cloudflare tunnel for webhook ingestion (requires cloudflared)
599
+ --port <number> Override the configured port
600
+ --host <address> Override the configured host
601
+ -h, --help Show this help message
602
+
603
+ By default, starts the server as a background daemon. The server
604
+ process ID is stored in ~/.drawlatch/drawlatch.pid.
605
+ `);
606
+ }
607
+
608
+ function printStopHelp() {
609
+ console.log(`
610
+ drawlatch stop
611
+
612
+ Stop the drawlatch remote server.
613
+
614
+ Usage: drawlatch stop [options]
615
+
616
+ Options:
617
+ -h, --help Show this help message
618
+
619
+ Sends SIGTERM to the server process and waits for graceful shutdown.
620
+ Falls back to SIGKILL if the process does not exit within 5 seconds.
621
+ `);
622
+ }
623
+
624
+ function printRestartHelp() {
625
+ console.log(`
626
+ drawlatch restart
627
+
628
+ Restart the drawlatch remote server.
629
+
630
+ Usage: drawlatch restart [options]
631
+
632
+ Options:
633
+ --port <number> Override the configured port
634
+ --host <address> Override the configured host
635
+ -h, --help Show this help message
636
+
637
+ Stops the running server (if any) and starts a new instance.
638
+ `);
639
+ }
640
+
641
+ function printStatusHelp() {
642
+ console.log(`
643
+ drawlatch status
644
+
645
+ Show server status.
646
+
647
+ Usage: drawlatch status [options]
648
+
649
+ Options:
650
+ -h, --help Show this help message
651
+
652
+ Displays PID, host, port, uptime, health check result, and
653
+ active session count.
654
+ `);
655
+ }
656
+
657
+ function printLogsHelp() {
658
+ console.log(`
659
+ drawlatch logs
660
+
661
+ View server logs.
662
+
663
+ Usage: drawlatch logs [options]
664
+
665
+ Options:
666
+ -n, --lines <number> Number of lines to show (default: 50)
667
+ --no-follow Print lines and exit (default: follow/tail)
668
+ -h, --help Show this help message
669
+
670
+ Log file: ~/.drawlatch/logs/drawlatch.log
671
+ `);
672
+ }
673
+
674
+ function printConfigHelp() {
675
+ console.log(`
676
+ drawlatch config
677
+
678
+ Show effective configuration.
679
+
680
+ Usage: drawlatch config [options]
681
+
682
+ Options:
683
+ --path Print the config file path only
684
+ -h, --help Show this help message
685
+
686
+ Reads ~/.drawlatch/remote.config.json and displays the effective
687
+ server configuration including callers and connections.
688
+ `);
689
+ }
690
+
691
+ function printGenerateKeysHelp() {
692
+ console.log(`
693
+ drawlatch generate-keys
694
+
695
+ Generate Ed25519 + X25519 keypairs for authentication and encryption.
696
+
697
+ Usage: drawlatch generate-keys <subcommand> [options]
698
+
699
+ Subcommands:
700
+ local [alias] Generate MCP proxy (local) keypair
701
+ Alias defaults to "default" if omitted.
702
+ Keys are stored in keys/local/<alias>/
703
+ remote Generate remote server keypair
704
+ --dir <path> Generate keypair in a custom directory
705
+ show <path> Show fingerprint of an existing keypair
706
+
707
+ Keys are saved as PEM files:
708
+ <dir>/signing.pub.pem Ed25519 public key (safe to share)
709
+ <dir>/signing.key.pem Ed25519 private key (keep secret!)
710
+ <dir>/exchange.pub.pem X25519 public key (safe to share)
711
+ <dir>/exchange.key.pem X25519 private key (keep secret!)
712
+ `);
713
+ }
@@ -12,7 +12,6 @@
12
12
  * - Maintains an audit log of all operations
13
13
  * - Rate-limits requests per session
14
14
  */
15
- import 'dotenv/config';
16
15
  import { type RemoteServerConfig, type ResolvedRoute } from '../shared/config.js';
17
16
  import { EncryptedChannel, type PublicKeyBundle } from '../shared/crypto/index.js';
18
17
  import { HandshakeResponder, type HandshakeInit } from '../shared/protocol/index.js';
@@ -100,4 +99,5 @@ export interface CreateAppOptions {
100
99
  ingestorManager?: IngestorManager;
101
100
  }
102
101
  export declare function createApp(options?: CreateAppOptions): import("express-serve-static-core").Express;
102
+ export declare function main(): void;
103
103
  //# sourceMappingURL=server.d.ts.map
@@ -12,13 +12,29 @@
12
12
  * - Maintains an audit log of all operations
13
13
  * - Rate-limits requests per session
14
14
  */
15
- import 'dotenv/config';
15
+ import dotenv from 'dotenv';
16
16
  import express from 'express';
17
17
  import fs from 'node:fs';
18
- import { loadRemoteConfig, resolveRoutes, resolveCallerRoutes, resolveSecrets, resolvePlaceholders, } from '../shared/config.js';
18
+ import { loadRemoteConfig, resolveRoutes, resolveCallerRoutes, resolveSecrets, resolvePlaceholders, getEnvFilePath, } from '../shared/config.js';
19
19
  import { loadKeyBundle, loadPublicKeys, EncryptedChannel, } from '../shared/crypto/index.js';
20
20
  import { HandshakeResponder, } from '../shared/protocol/index.js';
21
21
  import { IngestorManager } from './ingestors/index.js';
22
+ // ── Environment loading ─────────────────────────────────────────────────────
23
+ /** Load environment from ~/.drawlatch/.env, falling back to cwd .env (legacy). */
24
+ function loadEnvFile() {
25
+ const configDirEnvPath = getEnvFilePath();
26
+ if (fs.existsSync(configDirEnvPath)) {
27
+ dotenv.config({ path: configDirEnvPath });
28
+ return;
29
+ }
30
+ // Backward compat: fall back to cwd .env
31
+ const result = dotenv.config();
32
+ if (result.parsed) {
33
+ console.warn(`[remote] Loaded .env from working directory. ` +
34
+ `Move it to ${configDirEnvPath} for portable operation.`);
35
+ }
36
+ }
37
+ loadEnvFile();
22
38
  // ── State ──────────────────────────────────────────────────────────────────
23
39
  const sessions = new Map();
24
40
  const pendingHandshakes = new Map();
@@ -446,9 +462,24 @@ export function createApp(options = {}) {
446
462
  status: 'ok',
447
463
  activeSessions: sessions.size,
448
464
  uptime: process.uptime(),
465
+ tunnelUrl: process.env.DRAWLATCH_TUNNEL_URL ?? null,
449
466
  });
450
467
  });
451
468
  // ── Webhook receiver ─────────────────────────────────────────────────
469
+ // Trello (and potentially other services) send a HEAD request to the
470
+ // callback URL to verify it is reachable before activating the webhook.
471
+ // Respond with 200 if at least one ingestor is registered for the path.
472
+ app.head('/webhooks/:path', (req, res) => {
473
+ const webhookPath = req.params.path;
474
+ const mgr = app.locals.ingestorManager;
475
+ const ingestors = mgr.getWebhookIngestors(webhookPath);
476
+ if (ingestors.length === 0) {
477
+ res.status(404).end();
478
+ }
479
+ else {
480
+ res.status(200).end();
481
+ }
482
+ });
452
483
  app.post('/webhooks/:path', (req, res) => {
453
484
  const webhookPath = req.params.path;
454
485
  const mgr = app.locals.ingestorManager;
@@ -482,29 +513,80 @@ export function createApp(options = {}) {
482
513
  return app;
483
514
  }
484
515
  // ── Start ──────────────────────────────────────────────────────────────────
485
- function main() {
516
+ export function main() {
486
517
  const config = loadRemoteConfig();
518
+ const port = process.env.DRAWLATCH_PORT ? parseInt(process.env.DRAWLATCH_PORT, 10) : config.port;
519
+ const host = process.env.DRAWLATCH_HOST ?? config.host;
520
+ const useTunnel = process.env.DRAWLATCH_TUNNEL === '1';
487
521
  const app = createApp();
488
522
  const ingestorManager = app.locals.ingestorManager;
489
- const server = app.listen(config.port, config.host, () => {
490
- console.log(`[remote] Secure remote server listening on ${config.host}:${config.port}`);
491
- // Start ingestors after the server is listening
523
+ // Holds the tunnel stop function if a tunnel is active (set inside the
524
+ // listen callback, read by the shutdown handler — both share this scope).
525
+ let stopTunnel;
526
+ const server = app.listen(port, host, () => void (async () => {
527
+ console.log(`[remote] Secure remote server listening on ${host}:${port}`);
528
+ // If a tunnel was requested, start it before ingestors so that
529
+ // process.env.DRAWLATCH_TUNNEL_URL is available during secret resolution.
530
+ if (useTunnel) {
531
+ try {
532
+ const { startTunnel } = await import('./tunnel.js');
533
+ const tunnel = await startTunnel({ port, host });
534
+ stopTunnel = tunnel.stop;
535
+ process.env.DRAWLATCH_TUNNEL_URL = tunnel.url;
536
+ // Auto-populate callback URL env vars for webhook ingestors whose
537
+ // connection templates reference an env var that is not yet set.
538
+ for (const [callerAlias, _callerConfig] of Object.entries(config.callers)) {
539
+ const rawRoutes = resolveCallerRoutes(config, callerAlias);
540
+ for (const route of rawRoutes) {
541
+ const callbackTpl = route.ingestor?.webhook?.callbackUrl;
542
+ const webhookPath = route.ingestor?.webhook?.path;
543
+ if (!callbackTpl || !webhookPath)
544
+ continue;
545
+ // Extract env var name from "${VAR}" pattern
546
+ const match = /^\$\{(\w+)\}$/.exec(callbackTpl);
547
+ if (match) {
548
+ const envVar = match[1];
549
+ if (!process.env[envVar]) {
550
+ const fullUrl = `${tunnel.url}/webhooks/${webhookPath}`;
551
+ process.env[envVar] = fullUrl;
552
+ console.log(`[remote] Auto-set ${envVar}=${fullUrl}`);
553
+ }
554
+ }
555
+ }
556
+ }
557
+ console.log(`[remote] Tunnel active: ${tunnel.url}`);
558
+ console.log(`[remote] Webhook URL: ${tunnel.url}/webhooks/<path>`);
559
+ }
560
+ catch (err) {
561
+ console.error('[remote] Failed to start tunnel:', err);
562
+ console.error('[remote] Continuing without tunnel. Webhooks will only work on localhost.');
563
+ }
564
+ }
565
+ // Start ingestors after tunnel (if any) is ready
492
566
  ingestorManager.startAll().catch((err) => {
493
567
  console.error('[remote] Failed to start ingestors:', err);
494
568
  });
495
- });
496
- // Graceful shutdown: stop ingestors, then close the server.
569
+ })());
570
+ // Graceful shutdown: stop tunnel, then ingestors, then close the server.
497
571
  const shutdown = () => {
498
572
  console.log('[remote] Shutting down gracefully...');
499
- ingestorManager
500
- .stopAll()
501
- .catch((err) => {
502
- console.error('[remote] Error stopping ingestors:', err);
503
- })
504
- .finally(() => {
505
- server.close(() => {
506
- console.log('[remote] Server closed.');
507
- process.exit(0);
573
+ // Stop tunnel first (fast — just kills a child process)
574
+ const tunnelDone = stopTunnel
575
+ ? stopTunnel().catch((err) => {
576
+ console.error('[remote] Error stopping tunnel:', err);
577
+ })
578
+ : Promise.resolve();
579
+ void tunnelDone.then(() => {
580
+ ingestorManager
581
+ .stopAll()
582
+ .catch((err) => {
583
+ console.error('[remote] Error stopping ingestors:', err);
584
+ })
585
+ .finally(() => {
586
+ server.close(() => {
587
+ console.log('[remote] Server closed.');
588
+ process.exit(0);
589
+ });
508
590
  });
509
591
  });
510
592
  // Force exit after 10 seconds if connections don't drain
@@ -518,12 +600,8 @@ function main() {
518
600
  }
519
601
  // Only run when executed directly (not when imported as a library).
520
602
  // Check if the entry script is this file (covers both ts-node and compiled js).
521
- // Also check PM2's pm_exec_path for cluster mode where argv[1] is PM2's internal module.
522
603
  const entryScript = process.argv[1] ?? '';
523
- const pm2ExecPath = process.env.pm_exec_path ?? '';
524
- const isDirectRun = entryScript.endsWith('remote/server.ts') ||
525
- entryScript.endsWith('remote/server.js') ||
526
- pm2ExecPath.endsWith('remote/server.js');
604
+ const isDirectRun = entryScript.endsWith('remote/server.ts') || entryScript.endsWith('remote/server.js');
527
605
  if (isDirectRun) {
528
606
  try {
529
607
  main();
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Tunnel management for exposing the local Drawlatch server to the internet.
3
+ *
4
+ * Spawns a `cloudflared tunnel` process that creates a free Cloudflare Quick
5
+ * Tunnel, parses the assigned public URL from its stderr output, and provides
6
+ * graceful start/stop lifecycle management.
7
+ *
8
+ * The tunnel URL is injected into `process.env.DRAWLATCH_TUNNEL_URL` so that
9
+ * webhook ingestors can reference it during secret resolution (e.g., setting
10
+ * TRELLO_CALLBACK_URL=${DRAWLATCH_TUNNEL_URL}/webhooks/trello in .env).
11
+ */
12
+ export interface TunnelOptions {
13
+ /** Local port the server is listening on. */
14
+ port: number;
15
+ /** Local host the server is bound to. */
16
+ host: string;
17
+ /** Timeout (ms) to wait for the tunnel URL to be assigned. Default: 15 000. */
18
+ timeout?: number;
19
+ }
20
+ export interface TunnelResult {
21
+ /** The public HTTPS URL assigned by Cloudflare (e.g. https://abc.trycloudflare.com). */
22
+ url: string;
23
+ /** Gracefully stop the tunnel process. */
24
+ stop: () => Promise<void>;
25
+ }
26
+ /**
27
+ * Check whether the `cloudflared` binary is available on the system PATH.
28
+ * Resolves to `true` if available, `false` otherwise.
29
+ */
30
+ export declare function isCloudflaredAvailable(): Promise<boolean>;
31
+ /**
32
+ * Start a Cloudflare Quick Tunnel that forwards traffic from a public
33
+ * `*.trycloudflare.com` URL to the local Drawlatch server.
34
+ *
35
+ * Resolves once the tunnel URL has been parsed from cloudflared's output.
36
+ * Rejects if `cloudflared` is not installed, fails to start, or does not
37
+ * emit a URL within the configured timeout.
38
+ */
39
+ export declare function startTunnel(options: TunnelOptions): Promise<TunnelResult>;
40
+ //# sourceMappingURL=tunnel.d.ts.map
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Tunnel management for exposing the local Drawlatch server to the internet.
3
+ *
4
+ * Spawns a `cloudflared tunnel` process that creates a free Cloudflare Quick
5
+ * Tunnel, parses the assigned public URL from its stderr output, and provides
6
+ * graceful start/stop lifecycle management.
7
+ *
8
+ * The tunnel URL is injected into `process.env.DRAWLATCH_TUNNEL_URL` so that
9
+ * webhook ingestors can reference it during secret resolution (e.g., setting
10
+ * TRELLO_CALLBACK_URL=${DRAWLATCH_TUNNEL_URL}/webhooks/trello in .env).
11
+ */
12
+ import { spawn } from 'node:child_process';
13
+ import { createLogger } from '../shared/logger.js';
14
+ const log = createLogger('tunnel');
15
+ // ── Helpers ──────────────────────────────────────────────────────────────
16
+ /** Regex to extract the Cloudflare Quick Tunnel URL from cloudflared output. */
17
+ const TUNNEL_URL_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
18
+ const INSTALL_HINT = 'Install it: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/';
19
+ /**
20
+ * Check whether the `cloudflared` binary is available on the system PATH.
21
+ * Resolves to `true` if available, `false` otherwise.
22
+ */
23
+ export async function isCloudflaredAvailable() {
24
+ return new Promise((resolve) => {
25
+ const child = spawn('cloudflared', ['--version'], { stdio: 'ignore' });
26
+ child.on('error', () => resolve(false));
27
+ child.on('close', (code) => resolve(code === 0));
28
+ });
29
+ }
30
+ // ── Main entry point ─────────────────────────────────────────────────────
31
+ /**
32
+ * Start a Cloudflare Quick Tunnel that forwards traffic from a public
33
+ * `*.trycloudflare.com` URL to the local Drawlatch server.
34
+ *
35
+ * Resolves once the tunnel URL has been parsed from cloudflared's output.
36
+ * Rejects if `cloudflared` is not installed, fails to start, or does not
37
+ * emit a URL within the configured timeout.
38
+ */
39
+ export async function startTunnel(options) {
40
+ const { port, host, timeout = 15_000 } = options;
41
+ // ── Pre-flight check ────────────────────────────────────────────────
42
+ const available = await isCloudflaredAvailable();
43
+ if (!available) {
44
+ throw new Error(`cloudflared binary not found. ${INSTALL_HINT}`);
45
+ }
46
+ // ── Spawn cloudflared ───────────────────────────────────────────────
47
+ const localUrl = `http://${host}:${port}`;
48
+ log.info(`Starting Cloudflare Quick Tunnel → ${localUrl}`);
49
+ const child = spawn('cloudflared', ['tunnel', '--url', localUrl, '--no-autoupdate'], { stdio: ['ignore', 'pipe', 'pipe'] });
50
+ return new Promise((resolve, reject) => {
51
+ let settled = false;
52
+ let tunnelUrl = null;
53
+ // ── Timeout guard ───────────────────────────────────────────────
54
+ const timer = setTimeout(() => {
55
+ if (!settled) {
56
+ settled = true;
57
+ child.kill('SIGTERM');
58
+ reject(new Error(`Timed out after ${timeout}ms waiting for tunnel URL`));
59
+ }
60
+ }, timeout);
61
+ // ── Parse URL from stderr (cloudflared logs to stderr) ──────────
62
+ const handleData = (chunk) => {
63
+ const line = chunk.toString('utf-8');
64
+ log.debug(line.trimEnd());
65
+ if (settled)
66
+ return;
67
+ const match = TUNNEL_URL_RE.exec(line);
68
+ if (match) {
69
+ tunnelUrl = match[0];
70
+ settled = true;
71
+ clearTimeout(timer);
72
+ log.info(`Tunnel URL: ${tunnelUrl}`);
73
+ resolve({ url: tunnelUrl, stop: stopTunnel });
74
+ }
75
+ };
76
+ child.stderr?.on('data', handleData);
77
+ child.stdout?.on('data', handleData);
78
+ // ── Handle unexpected exit before URL is found ──────────────────
79
+ child.on('error', (err) => {
80
+ if (!settled) {
81
+ settled = true;
82
+ clearTimeout(timer);
83
+ reject(new Error(`cloudflared failed to start: ${err.message}`));
84
+ }
85
+ });
86
+ child.on('close', (code) => {
87
+ if (!settled) {
88
+ settled = true;
89
+ clearTimeout(timer);
90
+ reject(new Error(`cloudflared exited with code ${code} before emitting a URL`));
91
+ }
92
+ else if (tunnelUrl) {
93
+ // Tunnel was active but has now dropped
94
+ log.warn('cloudflared process exited unexpectedly — tunnel is down');
95
+ }
96
+ });
97
+ // ── Stop helper ─────────────────────────────────────────────────
98
+ async function stopTunnel() {
99
+ if (child.exitCode !== null)
100
+ return; // already exited
101
+ return new Promise((res) => {
102
+ const killTimer = setTimeout(() => {
103
+ log.warn('cloudflared did not exit in time, sending SIGKILL');
104
+ child.kill('SIGKILL');
105
+ }, 5_000);
106
+ child.on('close', () => {
107
+ clearTimeout(killTimer);
108
+ log.info('Tunnel stopped');
109
+ res();
110
+ });
111
+ child.kill('SIGTERM');
112
+ });
113
+ }
114
+ });
115
+ }
116
+ //# sourceMappingURL=tunnel.js.map
@@ -26,6 +26,7 @@ export declare function getKeysDir(): string;
26
26
  export declare function getLocalKeysDir(): string;
27
27
  export declare function getRemoteKeysDir(): string;
28
28
  export declare function getPeerKeysDir(): string;
29
+ export declare function getEnvFilePath(): string;
29
30
  /** MCP proxy (local) configuration */
30
31
  export interface ProxyConfig {
31
32
  /** Remote server URL */
@@ -45,6 +45,9 @@ export function getRemoteKeysDir() {
45
45
  export function getPeerKeysDir() {
46
46
  return path.join(getKeysDir(), 'peers');
47
47
  }
48
+ export function getEnvFilePath() {
49
+ return path.join(getConfigDir(), '.env');
50
+ }
48
51
  // ── Defaults ─────────────────────────────────────────────────────────────────
49
52
  function proxyDefaults() {
50
53
  return {
package/package.json CHANGED
@@ -1,15 +1,19 @@
1
1
  {
2
2
  "name": "@wolpertingerlabs/drawlatch",
3
- "version": "1.0.0-alpha.1",
3
+ "version": "1.0.0-alpha.3",
4
4
  "description": "Encrypted MCP proxy with mutual authentication. Local MCP server forwards requests through an encrypted channel to a remote secrets-holding server.",
5
5
  "type": "module",
6
6
  "main": "./dist/mcp/server.js",
7
7
  "types": "./dist/mcp/server.d.ts",
8
+ "bin": {
9
+ "drawlatch": "./bin/drawlatch.js"
10
+ },
8
11
  "files": [
9
12
  "dist",
10
13
  "!dist/**/*.test.*",
11
14
  "!dist/**/*.js.map",
12
15
  "!dist/**/*.d.ts.map",
16
+ "bin",
13
17
  "README.md",
14
18
  "CONNECTIONS.md",
15
19
  "INGESTORS.md"
@@ -52,7 +56,6 @@
52
56
  "generate-keys": "tsx src/cli/generate-keys.ts",
53
57
  "start:remote": "NODE_ENV=production node dist/remote/server.js",
54
58
  "start:mcp": "node dist/mcp/server.js",
55
- "redeploy:prod": "node start-server.js",
56
59
  "test": "vitest run",
57
60
  "test:watch": "vitest",
58
61
  "test:coverage": "vitest run --coverage",
@@ -61,7 +64,8 @@
61
64
  "format": "prettier --write 'src/**/*.ts' '*.{json,ts,js}'",
62
65
  "format:check": "prettier --check 'src/**/*.ts' '*.{json,ts,js}'",
63
66
  "prepublishOnly": "npm run lint && npm test && npm run build",
64
- "publish:dry-run": "npm publish --dry-run"
67
+ "publish:dry-run": "npm publish --dry-run",
68
+ "reload": "npm install --include=dev && npm run build && VERSION=$(node -p \"require('./package.json').version\") && npm pack --pack-destination /tmp && npm install -g \"/tmp/wolpertingerlabs-drawlatch-$VERSION.tgz\" && rm \"/tmp/wolpertingerlabs-drawlatch-$VERSION.tgz\""
65
69
  },
66
70
  "engines": {
67
71
  "node": ">=22"