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

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 (123) hide show
  1. package/CONNECTIONS.md +3 -0
  2. package/README.md +393 -465
  3. package/bin/drawlatch.js +1151 -49
  4. package/dist/auth/auth.d.ts +10 -0
  5. package/dist/auth/auth.js +146 -0
  6. package/dist/auth/env-writer.d.ts +6 -0
  7. package/dist/auth/env-writer.js +52 -0
  8. package/dist/auth/password.d.ts +8 -0
  9. package/dist/auth/password.js +28 -0
  10. package/dist/auth/sessions.d.ts +13 -0
  11. package/dist/auth/sessions.js +85 -0
  12. package/dist/cli/generate-keys.d.ts +4 -4
  13. package/dist/cli/generate-keys.js +30 -25
  14. package/dist/connections/{anthropic.json → ai/anthropic.json} +19 -1
  15. package/dist/connections/{devin.json → ai/devin.json} +7 -1
  16. package/dist/connections/{google-ai.json → ai/google-ai.json} +7 -1
  17. package/dist/connections/{openai.json → ai/openai.json} +7 -1
  18. package/dist/connections/{openrouter.json → ai/openrouter.json} +7 -1
  19. package/dist/connections/developer-tools/github.json +138 -0
  20. package/dist/connections/{hex.json → developer-tools/hex.json} +7 -1
  21. package/dist/connections/developer-tools/linear.json +75 -0
  22. package/dist/connections/{lichess.json → gaming/lichess.json} +7 -1
  23. package/dist/connections/messaging/agentmail.json +21 -0
  24. package/dist/connections/messaging/discord-bot.json +114 -0
  25. package/dist/connections/{discord-oauth.json → messaging/discord-oauth.json} +7 -1
  26. package/dist/connections/messaging/slack.json +75 -0
  27. package/dist/connections/messaging/telegram.json +66 -0
  28. package/dist/connections/{google.json → productivity/google.json} +7 -1
  29. package/dist/connections/productivity/notion.json +75 -0
  30. package/dist/connections/productivity/stripe.json +74 -0
  31. package/dist/connections/productivity/trello.json +113 -0
  32. package/dist/connections/{bluesky.json → social-media/bluesky.json} +41 -0
  33. package/dist/connections/social-media/mastodon.json +65 -0
  34. package/dist/connections/social-media/reddit.json +80 -0
  35. package/dist/connections/{twitch.json → social-media/twitch.json} +40 -0
  36. package/dist/connections/social-media/x.json +67 -0
  37. package/dist/mcp/server.js +544 -31
  38. package/dist/remote/admin-mutations.d.ts +43 -0
  39. package/dist/remote/admin-mutations.js +321 -0
  40. package/dist/remote/admin-types.d.ts +153 -0
  41. package/dist/remote/admin-types.js +11 -0
  42. package/dist/remote/admin.d.ts +37 -0
  43. package/dist/remote/admin.js +317 -0
  44. package/dist/remote/caller-bootstrap.d.ts +121 -0
  45. package/dist/remote/caller-bootstrap.js +270 -0
  46. package/dist/remote/ingestors/base-ingestor.d.ts +19 -3
  47. package/dist/remote/ingestors/base-ingestor.js +27 -6
  48. package/dist/remote/ingestors/discord/discord-gateway.d.ts +2 -2
  49. package/dist/remote/ingestors/discord/discord-gateway.js +29 -6
  50. package/dist/remote/ingestors/e2e/setup.d.ts +69 -0
  51. package/dist/remote/ingestors/e2e/setup.js +147 -0
  52. package/dist/remote/ingestors/manager.d.ts +110 -10
  53. package/dist/remote/ingestors/manager.js +449 -42
  54. package/dist/remote/ingestors/poll/poll-ingestor.d.ts +4 -2
  55. package/dist/remote/ingestors/poll/poll-ingestor.js +26 -5
  56. package/dist/remote/ingestors/registry.d.ts +2 -2
  57. package/dist/remote/ingestors/registry.js +2 -2
  58. package/dist/remote/ingestors/slack/socket-mode.d.ts +2 -2
  59. package/dist/remote/ingestors/slack/socket-mode.js +28 -6
  60. package/dist/remote/ingestors/types.d.ts +25 -0
  61. package/dist/remote/ingestors/webhook/base-webhook-ingestor.d.ts +46 -7
  62. package/dist/remote/ingestors/webhook/base-webhook-ingestor.js +115 -10
  63. package/dist/remote/ingestors/webhook/github-webhook-ingestor.d.ts +30 -0
  64. package/dist/remote/ingestors/webhook/github-webhook-ingestor.js +73 -2
  65. package/dist/remote/ingestors/webhook/lifecycle-types.d.ts +63 -0
  66. package/dist/remote/ingestors/webhook/lifecycle-types.js +12 -0
  67. package/dist/remote/ingestors/webhook/stripe-webhook-ingestor.js +2 -2
  68. package/dist/remote/ingestors/webhook/trello-webhook-ingestor.d.ts +17 -5
  69. package/dist/remote/ingestors/webhook/trello-webhook-ingestor.js +32 -26
  70. package/dist/remote/ingestors/webhook/webhook-lifecycle-manager.d.ts +46 -0
  71. package/dist/remote/ingestors/webhook/webhook-lifecycle-manager.js +261 -0
  72. package/dist/remote/server.d.ts +25 -33
  73. package/dist/remote/server.js +596 -187
  74. package/dist/remote/tool-dispatch.d.ts +84 -0
  75. package/dist/remote/tool-dispatch.js +910 -0
  76. package/dist/remote/triggers/rule-engine.d.ts +40 -0
  77. package/dist/remote/triggers/rule-engine.js +228 -0
  78. package/dist/remote/triggers/types.d.ts +69 -0
  79. package/dist/remote/triggers/types.js +10 -0
  80. package/dist/remote/tunnel-state.d.ts +14 -0
  81. package/dist/remote/tunnel-state.js +20 -0
  82. package/dist/remote/tunnel.d.ts +13 -0
  83. package/dist/remote/tunnel.js +33 -0
  84. package/dist/shared/config.d.ts +104 -25
  85. package/dist/shared/config.js +49 -36
  86. package/dist/shared/connections.d.ts +21 -5
  87. package/dist/shared/connections.js +56 -14
  88. package/dist/shared/crypto/index.d.ts +1 -0
  89. package/dist/shared/crypto/index.js +1 -0
  90. package/dist/shared/crypto/key-manager.d.ts +81 -0
  91. package/dist/shared/crypto/key-manager.js +174 -0
  92. package/dist/shared/env-utils.d.ts +42 -0
  93. package/dist/shared/env-utils.js +150 -0
  94. package/dist/shared/listener-config.d.ts +157 -0
  95. package/dist/shared/listener-config.js +10 -0
  96. package/dist/shared/migrations.d.ts +40 -0
  97. package/dist/shared/migrations.js +122 -0
  98. package/dist/shared/protocol/caller-bundle-crypto.d.ts +37 -0
  99. package/dist/shared/protocol/caller-bundle-crypto.js +62 -0
  100. package/dist/shared/protocol/caller-bundle.d.ts +75 -0
  101. package/dist/shared/protocol/caller-bundle.js +24 -0
  102. package/dist/shared/protocol/handshake.js +14 -1
  103. package/dist/shared/protocol/index.d.ts +2 -0
  104. package/dist/shared/protocol/index.js +2 -0
  105. package/dist/shared/protocol/sync-client.d.ts +52 -0
  106. package/dist/shared/protocol/sync-client.js +99 -0
  107. package/dist/shared/protocol/sync.d.ts +71 -0
  108. package/dist/shared/protocol/sync.js +176 -0
  109. package/frontend/dist/assets/index-BdCSPSZK.js +282 -0
  110. package/frontend/dist/assets/index-BmK26bY2.css +1 -0
  111. package/frontend/dist/index.html +15 -0
  112. package/package.json +65 -13
  113. package/dist/connections/discord-bot.json +0 -24
  114. package/dist/connections/github.json +0 -25
  115. package/dist/connections/linear.json +0 -29
  116. package/dist/connections/mastodon.json +0 -25
  117. package/dist/connections/notion.json +0 -33
  118. package/dist/connections/reddit.json +0 -28
  119. package/dist/connections/slack.json +0 -23
  120. package/dist/connections/stripe.json +0 -25
  121. package/dist/connections/telegram.json +0 -26
  122. package/dist/connections/trello.json +0 -25
  123. package/dist/connections/x.json +0 -27
package/bin/drawlatch.js CHANGED
@@ -28,15 +28,32 @@ const SERVER_ENTRY = join(PKG_ROOT, "dist/remote/server.js");
28
28
  const GENERATE_KEYS_ENTRY = join(PKG_ROOT, "dist/cli/generate-keys.js");
29
29
 
30
30
  // Import config helpers from compiled drawlatch code
31
- const { getConfigDir, getEnvFilePath, loadRemoteConfig } = await import(
31
+ const { getConfigDir, getEnvFilePath, getKeysDir, getCallerKeysDir, getServerKeysDir, getProxyConfigPath, getRemoteConfigPath, loadRemoteConfig } = await import(
32
32
  join(PKG_ROOT, "dist/shared/config.js")
33
33
  );
34
34
 
35
+ // Import crypto helpers for init command
36
+ const { generateKeyBundle, saveKeyBundle, extractPublicKeys, fingerprint, loadKeyBundle, loadPublicKeys } = await import(
37
+ join(PKG_ROOT, "dist/shared/crypto/index.js")
38
+ );
39
+
40
+ // Import connection template helpers
41
+ const { listConnectionTemplates } = await import(
42
+ join(PKG_ROOT, "dist/shared/connections.js")
43
+ );
44
+
45
+ // Import env utils
46
+ const { isSecretSetForCaller, loadEnvFile: loadEnvFileVars } = await import(
47
+ join(PKG_ROOT, "dist/shared/env-utils.js")
48
+ );
49
+
35
50
  const CONFIG_DIR = getConfigDir();
36
51
  const ENV_FILE = getEnvFilePath();
37
52
  const PID_FILE = join(CONFIG_DIR, "drawlatch.pid");
38
53
  const LOG_DIR = join(CONFIG_DIR, "logs");
39
54
  const LOG_FILE = join(LOG_DIR, "drawlatch.log");
55
+ const VERSION_CACHE_FILE = join(CONFIG_DIR, "latest-version.json");
56
+ const VERSION_CHECK_TTL_MS = 4 * 60 * 60 * 1000; // 4 hours
40
57
 
41
58
  // Read version from package.json
42
59
  const pkgJson = JSON.parse(
@@ -61,8 +78,17 @@ try {
61
78
  port: { type: "string" },
62
79
  host: { type: "string" },
63
80
  lines: { type: "string", short: "n", default: "50" },
64
- follow: { type: "boolean", default: true },
81
+ follow: { type: "boolean", default: false },
65
82
  path: { type: "boolean", default: false },
83
+ full: { type: "boolean", default: false },
84
+ requests: { type: "boolean", default: false },
85
+ ttl: { type: "string", default: "300" },
86
+ name: { type: "string" },
87
+ connections: { type: "string" },
88
+ endpoint: { type: "string" },
89
+ passphrase: { type: "boolean", default: false },
90
+ output: { type: "string", short: "o" },
91
+ into: { type: "string" },
66
92
  },
67
93
  strict: false,
68
94
  allowPositionals: true,
@@ -72,6 +98,11 @@ try {
72
98
  process.exit(1);
73
99
  }
74
100
 
101
+ // ── Kick off version check early (non-blocking) ──────────────────
102
+ const updateCheckPromise = (subcommand === null || subcommand === "help" || (values.help && !subcommand))
103
+ ? checkForUpdate()
104
+ : Promise.resolve(null);
105
+
75
106
  // ── Dispatch ──────────────────────────────────────────────────────
76
107
  if (values.version) {
77
108
  console.log(VERSION);
@@ -79,6 +110,11 @@ if (values.version) {
79
110
  }
80
111
  if (values.help && !subcommand) {
81
112
  printHelp();
113
+ const latestVersion = await Promise.race([
114
+ updateCheckPromise,
115
+ new Promise((r) => setTimeout(() => r(null), 100)),
116
+ ]);
117
+ if (latestVersion) console.log(formatUpdateNotice(latestVersion));
82
118
  process.exit(0);
83
119
  }
84
120
 
@@ -86,6 +122,13 @@ switch (subcommand) {
86
122
  case null:
87
123
  await cmdDefault();
88
124
  break;
125
+ case "init":
126
+ if (values.help) {
127
+ printInitHelp();
128
+ } else {
129
+ await cmdInit();
130
+ }
131
+ break;
89
132
  case "start":
90
133
  if (values.help) {
91
134
  printStartHelp();
@@ -135,8 +178,53 @@ switch (subcommand) {
135
178
  await cmdGenerateKeys();
136
179
  }
137
180
  break;
181
+ case "doctor":
182
+ if (values.help) {
183
+ printDoctorHelp();
184
+ } else {
185
+ await cmdDoctor();
186
+ }
187
+ break;
188
+ case "issue-caller":
189
+ if (values.help) {
190
+ printIssueCallerHelp();
191
+ } else {
192
+ await cmdIssueCaller();
193
+ }
194
+ break;
195
+ case "sync":
196
+ // Parked: kept in-tree as a high-assurance escape hatch, undocumented in
197
+ // the main help (superseded by `issue-caller`).
198
+ if (values.help) {
199
+ printSyncHelp();
200
+ } else {
201
+ await cmdSync();
202
+ }
203
+ break;
204
+ case "set-password":
205
+ case "change-password":
206
+ if (values.help) {
207
+ printSetPasswordHelp(subcommand);
208
+ } else {
209
+ await cmdSetPassword();
210
+ }
211
+ break;
212
+ case "watch":
213
+ if (values.help) {
214
+ printWatchHelp();
215
+ } else {
216
+ await cmdWatch();
217
+ }
218
+ break;
138
219
  case "help":
139
220
  printHelp();
221
+ {
222
+ const latestVersion = await Promise.race([
223
+ updateCheckPromise,
224
+ new Promise((r) => setTimeout(() => r(null), 100)),
225
+ ]);
226
+ if (latestVersion) console.log(formatUpdateNotice(latestVersion));
227
+ }
140
228
  break;
141
229
  default:
142
230
  console.error(`Unknown command: ${subcommand}\n`);
@@ -154,8 +242,98 @@ async function cmdDefault() {
154
242
  console.log("Drawlatch remote server is not running.\n");
155
243
  printHelp();
156
244
  }
245
+ // Show update notice only if the check already resolved (don't block on network)
246
+ const latestVersion = await Promise.race([
247
+ updateCheckPromise,
248
+ new Promise((r) => setTimeout(() => r(null), 100)),
249
+ ]);
250
+ if (latestVersion) console.log(formatUpdateNotice(latestVersion));
157
251
  }
158
252
 
253
+ async function cmdInit() {
254
+ console.log(`\nDrawlatch Setup`);
255
+ console.log(`===============\n`);
256
+
257
+ const steps = [];
258
+
259
+ // Step 1: Ensure config directory
260
+ ensureConfigDir();
261
+ steps.push(`Config directory: ${CONFIG_DIR}`);
262
+
263
+ // Step 2: Generate server keypair
264
+ const serverKeysDir = getServerKeysDir();
265
+ if (existsSync(join(serverKeysDir, "signing.key.pem"))) {
266
+ const existing = loadKeyBundle(serverKeysDir);
267
+ const fp = fingerprint(extractPublicKeys(existing));
268
+ steps.push(`Server keys: already exist (${fp})`);
269
+ } else {
270
+ const bundle = generateKeyBundle();
271
+ saveKeyBundle(bundle, serverKeysDir);
272
+ const fp = fingerprint(extractPublicKeys(bundle));
273
+ steps.push(`Server keys: CREATED (${fp})`);
274
+ }
275
+
276
+ // Step 3: Scaffold proxy.config.json
277
+ const proxyConfigPath = getProxyConfigPath();
278
+ if (existsSync(proxyConfigPath)) {
279
+ steps.push(`Proxy config: already exists`);
280
+ } else {
281
+ const proxyConfig = {
282
+ remoteUrl: "http://127.0.0.1:9999",
283
+ connectTimeout: 10000,
284
+ requestTimeout: 300000,
285
+ };
286
+ writeFileSync(proxyConfigPath, JSON.stringify(proxyConfig, null, 2) + "\n", { mode: 0o600 });
287
+ steps.push(`Proxy config: CREATED`);
288
+ }
289
+
290
+ // Step 4: Scaffold remote.config.json
291
+ const remoteConfigPath = getRemoteConfigPath();
292
+ if (existsSync(remoteConfigPath)) {
293
+ steps.push(`Remote config: already exists`);
294
+ } else {
295
+ const remoteConfig = {
296
+ host: "0.0.0.0",
297
+ port: 9999,
298
+ rateLimitPerMinute: 60,
299
+ callers: {},
300
+ };
301
+ writeFileSync(remoteConfigPath, JSON.stringify(remoteConfig, null, 2) + "\n", { mode: 0o600 });
302
+ steps.push(`Remote config: CREATED`);
303
+ }
304
+
305
+ // Step 5: Scaffold .env file
306
+ if (existsSync(ENV_FILE)) {
307
+ steps.push(`.env file: already exists`);
308
+ } else {
309
+ const envLines = [
310
+ "# Drawlatch environment secrets",
311
+ "# Set tokens for your enabled connections",
312
+ "# Secrets are prefixed per caller (e.g., DEFAULT_GITHUB_TOKEN)",
313
+ "",
314
+ ];
315
+ writeFileSync(ENV_FILE, envLines.join("\n") + "\n", { mode: 0o600 });
316
+ steps.push(`.env file: CREATED`);
317
+ }
318
+
319
+ // Print summary
320
+ for (const step of steps) {
321
+ console.log(` ${step}`);
322
+ }
323
+
324
+ console.log(`\nSetup complete! Next steps:\n`);
325
+ console.log(` 1. Set the dashboard password:`);
326
+ console.log(` drawlatch set-password\n`);
327
+ console.log(` 2. Start the remote server:`);
328
+ console.log(` drawlatch start\n`);
329
+ console.log(` 3. Issue a caller credential bundle (or use the dashboard Callers page):`);
330
+ console.log(` drawlatch issue-caller callboard-prod -o callboard-prod.drawlatch-caller.json\n`);
331
+ console.log(` 4. Verify your setup:`);
332
+ console.log(` drawlatch doctor\n`);
333
+ }
334
+
335
+
336
+
159
337
  async function cmdStart() {
160
338
  if (values.foreground) return cmdStartForeground();
161
339
 
@@ -211,10 +389,16 @@ async function cmdStart() {
211
389
  }
212
390
  console.log(` Logs: drawlatch logs`);
213
391
  } else {
214
- console.log(
215
- `\nServer started (PID ${child.pid}) but health check did not pass.`,
216
- );
217
- console.log(` Check logs: drawlatch logs`);
392
+ const stillAlive = isProcessAlive(child.pid);
393
+ if (stillAlive) {
394
+ console.log(
395
+ `\nServer started (PID ${child.pid}) but health check did not pass within 5s.`,
396
+ );
397
+ console.log(` The server process is still running — it may need more time.`);
398
+ } else {
399
+ console.log(`\nServer process (PID ${child.pid}) exited before becoming healthy.`);
400
+ cleanPidFile();
401
+ }
218
402
  await diagnoseStartFailure();
219
403
  }
220
404
  }
@@ -305,6 +489,10 @@ async function cmdStatus() {
305
489
  console.log("Drawlatch remote server is running.");
306
490
  console.log(` PID: ${pid}`);
307
491
  console.log(` Listening: ${host}:${port}`);
492
+ console.log(` Dashboard: http://${connectHost(host)}:${port}/`);
493
+ console.log(
494
+ ` Password: ${isPasswordConfigured() ? "configured" : "not set (run: drawlatch set-password)"}`,
495
+ );
308
496
  console.log(` Uptime: ${uptime}`);
309
497
  console.log(
310
498
  ` Health: ${healthData ? "healthy" : "unhealthy (not responding)"}`,
@@ -326,39 +514,154 @@ async function cmdLogs() {
326
514
 
327
515
  const lines = parseInt(values.lines, 10) || 50;
328
516
  const follow = values.follow;
517
+ const showRequests = values.requests;
329
518
 
330
519
  const tailArgs = follow
331
520
  ? ["-n", String(lines), "-f", LOG_FILE]
332
521
  : ["-n", String(lines), LOG_FILE];
333
522
 
334
- const tail = spawn("tail", tailArgs, { stdio: "inherit" });
523
+ if (showRequests) {
524
+ // Show everything — pipe directly to stdout
525
+ const tail = spawn("tail", tailArgs, { stdio: "inherit" });
335
526
 
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);
527
+ tail.on("error", () => logsFallback(lines, follow, null));
528
+
529
+ process.on("SIGINT", () => {
530
+ tail.kill();
531
+ process.exit(0);
532
+ });
533
+
534
+ await new Promise((res) => tail.on("close", res));
535
+ } else {
536
+ // Filter out [audit] lines (request/response noise from poll_events etc.)
537
+ const tail = spawn("tail", tailArgs, { stdio: ["ignore", "pipe", "inherit"] });
538
+ const grepProc = spawn("grep", ["-v", "^\\[audit\\]"], {
539
+ stdio: [tail.stdout, "inherit", "inherit"],
540
+ });
541
+
542
+ tail.on("error", () => logsFallback(lines, follow, "[audit]"));
543
+
544
+ process.on("SIGINT", () => {
545
+ tail.kill();
546
+ grepProc.kill();
547
+ process.exit(0);
548
+ });
549
+
550
+ await new Promise((res) => grepProc.on("close", res));
551
+ }
552
+ }
553
+
554
+ function logsFallback(lines, follow, filterPrefix) {
555
+ try {
556
+ const content = readFileSync(LOG_FILE, "utf-8");
557
+ let allLines = content.split("\n");
558
+ if (filterPrefix) {
559
+ allLines = allLines.filter((l) => !l.startsWith(filterPrefix));
351
560
  }
352
- });
561
+ const lastLines = allLines.slice(-lines).join("\n");
562
+ console.log(lastLines);
563
+ if (follow) {
564
+ console.log(
565
+ "\n(Live following not available \u2014 'tail' command not found)",
566
+ );
567
+ }
568
+ } catch (err) {
569
+ console.error(`Error reading log file: ${err.message}`);
570
+ process.exit(1);
571
+ }
572
+ }
573
+
574
+ async function cmdWatch() {
575
+ const config = loadRemoteConfig();
576
+ const port = config.port;
577
+ const host = config.host;
578
+
579
+ // Verify the server is running
580
+ const healthy = await healthCheck(host, port);
581
+ if (!healthy) {
582
+ console.error("Remote server is not running. Start it first:");
583
+ console.error(" drawlatch start");
584
+ process.exit(1);
585
+ }
586
+
587
+ const showFull = values.full;
588
+ const sourceFilter = positionals[0] || null;
353
589
 
354
- // Forward SIGINT to cleanly exit
590
+ console.log("Watching for events" + (sourceFilter ? ` from "${sourceFilter}"` : "") + "...");
591
+ console.log("Press Ctrl+C to stop.\n");
592
+
593
+ const controller = new AbortController();
355
594
  process.on("SIGINT", () => {
356
- tail.kill();
595
+ controller.abort();
357
596
  process.exit(0);
358
597
  });
359
598
 
360
- // Wait for tail to exit (when using --no-follow)
361
- await new Promise((res) => tail.on("close", res));
599
+ try {
600
+ const res = await fetch(
601
+ `http://${connectHost(host)}:${port}/events/stream`,
602
+ { signal: controller.signal },
603
+ );
604
+
605
+ if (!res.ok) {
606
+ console.error(`Failed to connect to event stream: HTTP ${res.status}`);
607
+ process.exit(1);
608
+ }
609
+
610
+ const decoder = new TextDecoder();
611
+ let buffer = "";
612
+
613
+ for await (const chunk of res.body) {
614
+ buffer += decoder.decode(chunk, { stream: true });
615
+
616
+ // Parse SSE lines
617
+ let newlineIdx;
618
+ while ((newlineIdx = buffer.indexOf("\n\n")) !== -1) {
619
+ const message = buffer.slice(0, newlineIdx);
620
+ buffer = buffer.slice(newlineIdx + 2);
621
+
622
+ for (const line of message.split("\n")) {
623
+ if (!line.startsWith("data: ")) continue;
624
+
625
+ try {
626
+ const event = JSON.parse(line.slice(6));
627
+
628
+ // Apply source filter if set
629
+ if (sourceFilter && event.source !== sourceFilter) continue;
630
+
631
+ const time = new Date(event.receivedAt).toLocaleTimeString();
632
+ const source = event.source;
633
+ const instance = event.instanceId ? `:${event.instanceId}` : "";
634
+ const caller = event.callerAlias || "?";
635
+ const eventType = event.eventType;
636
+
637
+ const dataStr = JSON.stringify(event.data);
638
+
639
+ if (showFull) {
640
+ console.log(
641
+ `\x1b[2m${time}\x1b[0m \x1b[36m${source}${instance}\x1b[0m \x1b[33m${eventType}\x1b[0m \x1b[2m(${caller})\x1b[0m`
642
+ );
643
+ console.log(dataStr);
644
+ console.log("");
645
+ } else {
646
+ const preview =
647
+ dataStr.length > 100
648
+ ? dataStr.slice(0, 100) + "…"
649
+ : dataStr;
650
+ console.log(
651
+ `\x1b[2m${time}\x1b[0m \x1b[36m${source}${instance}\x1b[0m \x1b[33m${eventType}\x1b[0m \x1b[2m(${caller})\x1b[0m ${preview}`
652
+ );
653
+ }
654
+ } catch {
655
+ // Skip malformed SSE data
656
+ }
657
+ }
658
+ }
659
+ }
660
+ } catch (err) {
661
+ if (err.name === "AbortError") return;
662
+ console.error(`Event stream error: ${err.message}`);
663
+ process.exit(1);
664
+ }
362
665
  }
363
666
 
364
667
  function cmdConfig() {
@@ -378,14 +681,51 @@ function cmdConfig() {
378
681
  console.log(` Host: ${config.host}`);
379
682
  console.log(` Port: ${config.port}`);
380
683
  console.log(` Rate limit: ${config.rateLimitPerMinute} req/min`);
381
- console.log(` Local keys dir: ${config.localKeysDir}`);
684
+ console.log(` Server keys dir: ${getServerKeysDir()}`);
685
+
686
+ // Show key fingerprints if keys exist
687
+ const serverKeysPath = getServerKeysDir();
688
+ if (existsSync(join(serverKeysPath, "signing.key.pem"))) {
689
+ try {
690
+ const serverKeys = loadKeyBundle(serverKeysPath);
691
+ console.log(` Server key fp: ${fingerprint(extractPublicKeys(serverKeys))}`);
692
+ } catch { /* skip */ }
693
+ }
382
694
 
383
695
  const callerEntries = Object.entries(config.callers || {});
384
696
  console.log(` Callers: ${callerEntries.length}`);
697
+
698
+ // Build template map for secret status checking
699
+ const templates = listConnectionTemplates();
700
+ const templateMap = new Map(templates.map((t) => [t.alias, t]));
701
+
702
+ // Load .env vars into process.env so isSecretSetForCaller works.
703
+ // Force-set to override empty shell env vars.
704
+ const envVars = loadEnvFileVars();
705
+ for (const [k, v] of Object.entries(envVars)) {
706
+ process.env[k] = v;
707
+ }
708
+
385
709
  for (const [alias, caller] of callerEntries) {
386
710
  console.log(
387
711
  ` ${alias}: ${caller.connections ? caller.connections.length : 0} connection(s)`,
388
712
  );
713
+ for (const connName of caller.connections || []) {
714
+ const tpl = templateMap.get(connName);
715
+ if (!tpl) {
716
+ console.log(` ${connName} (custom connector)`);
717
+ continue;
718
+ }
719
+ const secretStatuses = tpl.requiredSecrets.map((s) => {
720
+ const set = isSecretSetForCaller(s, alias, caller.env);
721
+ return `${s}: ${set ? "SET" : "NOT SET"}`;
722
+ });
723
+ if (secretStatuses.length > 0) {
724
+ console.log(` ${connName}: ${secretStatuses.join(", ")}`);
725
+ } else {
726
+ console.log(` ${connName}: (no secrets required)`);
727
+ }
728
+ }
389
729
  }
390
730
 
391
731
  console.log(
@@ -413,6 +753,512 @@ async function cmdGenerateKeys() {
413
753
  process.exit(child.exitCode ?? 0);
414
754
  }
415
755
 
756
+
757
+ async function cmdDoctor() {
758
+ console.log(`\nDrawlatch Setup Check`);
759
+ console.log(`=====================\n`);
760
+
761
+ let passed = 0;
762
+ let failed = 0;
763
+
764
+ function check(label, ok, fix) {
765
+ const padded = label.padEnd(50);
766
+ if (ok) {
767
+ console.log(` ${padded} PASS`);
768
+ passed++;
769
+ } else {
770
+ console.log(` ${padded} FAIL`);
771
+ if (fix) console.log(` -> ${fix}`);
772
+ failed++;
773
+ }
774
+ return ok;
775
+ }
776
+
777
+ // Load .env for secret checks
778
+ const envVars = loadEnvFileVars();
779
+ for (const [k, v] of Object.entries(envVars)) {
780
+ process.env[k] = v;
781
+ }
782
+
783
+ // 1. Config directory
784
+ check("Config directory", existsSync(CONFIG_DIR), "Run: drawlatch init");
785
+
786
+ // 2. Remote config
787
+ const remoteConfigPath = getRemoteConfigPath();
788
+ const hasRemoteConfig = existsSync(remoteConfigPath);
789
+ check("Remote config (remote.config.json)", hasRemoteConfig, "Run: drawlatch init");
790
+
791
+ // 3. Proxy config
792
+ const proxyConfigPath = getProxyConfigPath();
793
+ const hasProxyConfig = existsSync(proxyConfigPath);
794
+ check("Proxy config (proxy.config.json)", hasProxyConfig, "Run: drawlatch init");
795
+
796
+ // 4. Server keys
797
+ const serverKeysPath = getServerKeysDir();
798
+ const hasServerKeys = existsSync(join(serverKeysPath, "signing.key.pem")) &&
799
+ existsSync(join(serverKeysPath, "exchange.key.pem")) &&
800
+ existsSync(join(serverKeysPath, "signing.pub.pem")) &&
801
+ existsSync(join(serverKeysPath, "exchange.pub.pem"));
802
+ check("Server keys (keys/server/)", hasServerKeys, "Run: drawlatch generate-keys server");
803
+
804
+ if (hasServerKeys) {
805
+ try {
806
+ const sk = loadKeyBundle(serverKeysPath);
807
+ console.log(` Fingerprint: ${fingerprint(extractPublicKeys(sk))}`);
808
+ } catch { /* skip */ }
809
+ }
810
+
811
+ // 5. Caller keys — check each caller from config
812
+ if (hasRemoteConfig) {
813
+ try {
814
+ const config = loadRemoteConfig();
815
+ const callersDir = getCallerKeysDir();
816
+ for (const [alias] of Object.entries(config.callers)) {
817
+ const callerDir = join(callersDir, alias);
818
+ const hasCallerKeys = existsSync(join(callerDir, "signing.pub.pem")) &&
819
+ existsSync(join(callerDir, "exchange.pub.pem"));
820
+ check(`Caller keys for "${alias}" (keys/callers/${alias}/)`, hasCallerKeys, `Run: drawlatch generate-keys caller ${alias} (or sync)`);
821
+
822
+ if (hasCallerKeys) {
823
+ try {
824
+ const pk = loadPublicKeys(callerDir);
825
+ console.log(` Fingerprint: ${fingerprint(pk)}`);
826
+ } catch { /* skip */ }
827
+ }
828
+ }
829
+ } catch { /* skip */ }
830
+ }
831
+
832
+ // 8. .env file
833
+ check("Environment file (.env)", existsSync(ENV_FILE), "Run: drawlatch init");
834
+
835
+ // 9. Required secrets for each caller's connections
836
+ if (hasRemoteConfig) {
837
+ try {
838
+ const config = loadRemoteConfig();
839
+ const templates = listConnectionTemplates();
840
+ const templateMap = new Map(templates.map((t) => [t.alias, t]));
841
+
842
+ for (const [callerAlias, caller] of Object.entries(config.callers)) {
843
+ for (const connName of caller.connections) {
844
+ const tpl = templateMap.get(connName);
845
+ if (!tpl) continue;
846
+ for (const secret of tpl.requiredSecrets) {
847
+ const isSet = isSecretSetForCaller(secret, callerAlias, caller.env);
848
+ check(` ${callerAlias}/${connName}: ${secret}`, isSet, `Set ${secret} in ${ENV_FILE}`);
849
+ }
850
+ }
851
+ }
852
+ } catch { /* skip */ }
853
+ }
854
+
855
+ // 10. Remote server health check
856
+ if (hasRemoteConfig) {
857
+ try {
858
+ const config = loadRemoteConfig();
859
+ const healthy = await healthCheck(config.host, config.port);
860
+ check(`Remote server health (${config.host}:${config.port})`, healthy, "Run: drawlatch start");
861
+ } catch {
862
+ check("Remote server health", false, "Run: drawlatch start");
863
+ }
864
+ }
865
+
866
+ // Summary
867
+ const total = passed + failed;
868
+ console.log(`\nResult: ${passed}/${total} checks passed`);
869
+ if (failed > 0) {
870
+ console.log(`Fix the ${failed} failing check(s) above and re-run: drawlatch doctor`);
871
+ } else {
872
+ console.log(`All checks passed! Your setup is ready.`);
873
+ }
874
+ console.log();
875
+
876
+ process.exit(failed > 0 ? 1 : 0);
877
+ }
878
+
879
+ async function cmdSync() {
880
+ const config = loadRemoteConfig();
881
+ const port = config.port;
882
+ const host = config.host;
883
+ const ttlSeconds = parseInt(values.ttl, 10) || 300;
884
+ const ttlMs = ttlSeconds * 1000;
885
+
886
+ // Check that the server is running
887
+ const healthy = await healthCheck(host, port);
888
+ if (!healthy) {
889
+ console.error(
890
+ "Error: Drawlatch remote server is not running.\n" +
891
+ " Start it first: drawlatch start\n",
892
+ );
893
+ process.exit(1);
894
+ }
895
+
896
+ // Import sync helpers from compiled drawlatch code
897
+ const { generateSyncCode, generateSyncEncryptionKey } = await import(
898
+ join(PKG_ROOT, "dist/shared/protocol/sync.js")
899
+ );
900
+
901
+ const inviteCode = generateSyncCode();
902
+ const encryptionKey = generateSyncEncryptionKey();
903
+
904
+ console.log("\nStarting key exchange...\n");
905
+ console.log(` Invite code: ${inviteCode}`);
906
+ console.log(` Encryption key: ${encryptionKey}`);
907
+ console.log(
908
+ "\nGive both values to the callboard operator.",
909
+ );
910
+ console.log("They will provide a confirm code.\n");
911
+
912
+ // Prompt for confirm code
913
+ const confirmCode = await promptInput("Enter confirm code: ");
914
+ if (!confirmCode.trim()) {
915
+ console.error("No confirm code entered. Aborting.");
916
+ process.exit(1);
917
+ }
918
+
919
+ // Open the sync session on the running server
920
+ try {
921
+ const controller = new AbortController();
922
+ const timeout = setTimeout(() => controller.abort(), 5000);
923
+ const res = await fetch(`http://${host}:${port}/sync/listen`, {
924
+ method: "POST",
925
+ headers: { "Content-Type": "application/json" },
926
+ body: JSON.stringify({
927
+ inviteCode,
928
+ confirmCode: confirmCode.trim(),
929
+ encryptionKey,
930
+ ttlMs,
931
+ }),
932
+ signal: controller.signal,
933
+ });
934
+ clearTimeout(timeout);
935
+
936
+ if (!res.ok) {
937
+ const body = await res.json().catch(() => ({}));
938
+ console.error(`Failed to open sync session: ${body.error || res.statusText}`);
939
+ process.exit(1);
940
+ }
941
+ } catch (err) {
942
+ console.error(`Failed to reach server: ${err.message}`);
943
+ process.exit(1);
944
+ }
945
+
946
+ console.log(`\nWaiting for sync from callboard (timeout: ${ttlSeconds}s)...`);
947
+
948
+ // Poll for completion
949
+ const start = Date.now();
950
+ while (Date.now() - start < ttlMs) {
951
+ await sleep(1000);
952
+
953
+ try {
954
+ const controller = new AbortController();
955
+ const timeout = setTimeout(() => controller.abort(), 3000);
956
+ const res = await fetch(`http://${host}:${port}/sync/status`, {
957
+ signal: controller.signal,
958
+ });
959
+ clearTimeout(timeout);
960
+
961
+ if (!res.ok) continue;
962
+ const status = await res.json();
963
+
964
+ if (status.completed) {
965
+ console.log(`\nSync complete!`);
966
+ console.log(` Caller alias: ${status.callerAlias}`);
967
+ console.log(` Fingerprint: ${status.fingerprint}`);
968
+ console.log(
969
+ ` Keys saved to: ${join(CONFIG_DIR, "keys", "callers", status.callerAlias)}/`,
970
+ );
971
+ console.log(`\nThe caller can now connect (no server restart needed).`);
972
+ console.log(`\nTo grant API access, add connections in ${join(CONFIG_DIR, "remote.config.json")}:`);
973
+ console.log(` "callers": {`);
974
+ console.log(` "${status.callerAlias}": {`);
975
+ console.log(` "connections": ["github", "slack", ...]`);
976
+ console.log(` }`);
977
+ console.log(` }`);
978
+ console.log(`\nThen set the required secrets in ${ENV_FILE}`);
979
+ console.log();
980
+ process.exit(0);
981
+ }
982
+
983
+ if (!status.active) {
984
+ console.error("\nSync session expired or was cancelled.");
985
+ process.exit(1);
986
+ }
987
+ } catch {
988
+ // Transient fetch error, keep polling
989
+ }
990
+ }
991
+
992
+ console.error("\nSync session timed out.");
993
+ process.exit(1);
994
+ }
995
+
996
+ // ── issue-caller ──────────────────────────────────────────────────────────
997
+ //
998
+ // Mint a caller credential bundle using the same primitive as the dashboard
999
+ // "Issue credentials" action. Two delivery modes:
1000
+ // - default / -o file : emit the {alias}.drawlatch-caller.json bundle (the
1001
+ // caller PRIVATE key is in the file; drawlatch keeps only the public key).
1002
+ // - --into <keysDir> : write the UNPACKED key files straight into a
1003
+ // co-located callboard's keys dir (same-host convenience).
1004
+ async function cmdIssueCaller() {
1005
+ const alias = positionals[0];
1006
+ if (!alias) {
1007
+ console.error("Error: caller alias is required.\n Usage: drawlatch issue-caller <alias> [options]");
1008
+ process.exit(1);
1009
+ }
1010
+
1011
+ ensureConfigDir();
1012
+
1013
+ const { issueCallerBundle, issueLocalCaller, CALLER_ALIAS_REGEX } = await import(
1014
+ join(PKG_ROOT, "dist/remote/caller-bootstrap.js")
1015
+ );
1016
+
1017
+ if (!CALLER_ALIAS_REGEX.test(alias)) {
1018
+ console.error(
1019
+ "Error: invalid alias. Use letters, numbers, dashes or underscores (must start alphanumeric).",
1020
+ );
1021
+ process.exit(1);
1022
+ }
1023
+
1024
+ const connections = values.connections
1025
+ ? values.connections.split(",").map((s) => s.trim()).filter(Boolean)
1026
+ : undefined;
1027
+ const name = values.name;
1028
+
1029
+ try {
1030
+ // ── Mode 2: write unpacked key files directly into callboard's keys dir ──
1031
+ if (values.into) {
1032
+ const keysDir = resolve(values.into);
1033
+ const res = issueLocalCaller({
1034
+ alias,
1035
+ keysDir,
1036
+ ...(connections ? { connections } : {}),
1037
+ ...(name ? { name } : {}),
1038
+ });
1039
+ console.log(`\nIssued local caller "${res.alias}" (write-to-path).`);
1040
+ console.log(` Key files: ${res.callerKeysDir}/`);
1041
+ console.log(` Server keys: ${join(keysDir, "server")}/`);
1042
+ console.log(` Fingerprint: ${res.fingerprint}`);
1043
+ console.log(` Connections: ${res.connections.join(", ") || "(none)"}`);
1044
+ notifyDaemonReloadNeeded();
1045
+ return;
1046
+ }
1047
+
1048
+ // ── Mode 1: emit a bundle (file or stdout) ──────────────────────────────
1049
+ const config = loadRemoteConfig();
1050
+ const endpoint = values.endpoint || `http://${connectHost(config.host)}:${config.port}`;
1051
+
1052
+ let passphrase;
1053
+ if (values.passphrase) {
1054
+ let confirm;
1055
+ if (process.stdin.isTTY) {
1056
+ passphrase = await promptPasswordTTY("Passphrase to protect private keys: ");
1057
+ confirm = await promptPasswordTTY("Confirm passphrase: ");
1058
+ } else {
1059
+ // Piped input — read both lines from one readline interface.
1060
+ const { createInterface } = await import("node:readline");
1061
+ const rl = createInterface({ input: process.stdin });
1062
+ [passphrase, confirm] = await readTwoLines(rl);
1063
+ rl.close();
1064
+ }
1065
+ if (!passphrase) {
1066
+ console.error("Error: passphrase cannot be empty.");
1067
+ process.exit(1);
1068
+ }
1069
+ if (passphrase !== confirm) {
1070
+ console.error("Error: passphrases do not match.");
1071
+ process.exit(1);
1072
+ }
1073
+ }
1074
+
1075
+ const bundle = issueCallerBundle({
1076
+ alias,
1077
+ endpointUrl: endpoint,
1078
+ ...(connections ? { connections } : {}),
1079
+ ...(name ? { name } : {}),
1080
+ ...(passphrase ? { passphrase } : {}),
1081
+ });
1082
+
1083
+ const json = JSON.stringify(bundle, null, 2) + "\n";
1084
+
1085
+ if (values.output) {
1086
+ const outPath = resolve(values.output);
1087
+ writeFileSync(outPath, json, { mode: 0o600 });
1088
+ console.error(`\nWrote credential bundle to ${outPath}`);
1089
+ console.error(` Caller: ${bundle.callerAlias}`);
1090
+ console.error(` Fingerprint: ${bundle.fingerprint}`);
1091
+ console.error(` Endpoint: ${bundle.endpointUrl}`);
1092
+ console.error(
1093
+ bundle.encryption
1094
+ ? ` Private keys: passphrase-protected (share the passphrase out-of-band).`
1095
+ : ` Private keys: PLAINTEXT in the file — protect it (or use --passphrase).`,
1096
+ );
1097
+ console.error(` This file contains the caller PRIVATE key and won't be re-issued.`);
1098
+ notifyDaemonReloadNeeded();
1099
+ } else {
1100
+ // Bundle (with private keys) to stdout; notes to stderr.
1101
+ process.stdout.write(json);
1102
+ console.error(`\n# Caller "${bundle.callerAlias}" issued (fingerprint ${bundle.fingerprint}).`);
1103
+ if (!bundle.encryption) {
1104
+ console.error(`# WARNING: private keys are PLAINTEXT above. Use --passphrase or -o to a 0600 file.`);
1105
+ }
1106
+ notifyDaemonReloadNeeded();
1107
+ }
1108
+ } catch (err) {
1109
+ console.error(`Error: ${err.message}`);
1110
+ process.exit(1);
1111
+ }
1112
+ }
1113
+
1114
+ /** If the daemon is running, it won't authorize a freshly-issued caller until
1115
+ * it reloads its peer set — tell the user to restart it. (The dashboard issue
1116
+ * flow live-reloads peers; the offline CLI path cannot.) */
1117
+ function notifyDaemonReloadNeeded() {
1118
+ if (readPid()) {
1119
+ console.error(`\nNote: restart the daemon so it authorizes the new caller — drawlatch restart`);
1120
+ }
1121
+ }
1122
+
1123
+ async function promptInput(prompt) {
1124
+ const { createInterface } = await import("node:readline");
1125
+ return new Promise((resolve) => {
1126
+ const rl = createInterface({
1127
+ input: process.stdin,
1128
+ output: process.stdout,
1129
+ });
1130
+ rl.question(prompt, (answer) => {
1131
+ rl.close();
1132
+ resolve(answer);
1133
+ });
1134
+ });
1135
+ }
1136
+
1137
+ // ── set-password / change-password ────────────────────────────────
1138
+ //
1139
+ // Sets the dashboard/admin password. The scrypt hash + salt are written to
1140
+ // ~/.drawlatch/.env (AUTH_PASSWORD_HASH / AUTH_PASSWORD_SALT) using the same
1141
+ // env-writer the daemon's change-password handler uses, so both stay in sync.
1142
+ async function cmdSetPassword() {
1143
+ ensureConfigDir();
1144
+
1145
+ const { hashPassword, generateSalt } = await import(
1146
+ join(PKG_ROOT, "dist/auth/password.js")
1147
+ );
1148
+ const { updateEnvFile } = await import(
1149
+ join(PKG_ROOT, "dist/auth/env-writer.js")
1150
+ );
1151
+
1152
+ let password, confirm;
1153
+ if (process.stdin.isTTY) {
1154
+ password = await promptPasswordTTY("Enter new password: ");
1155
+ if (!password) {
1156
+ console.error("Error: password cannot be empty.");
1157
+ process.exit(1);
1158
+ }
1159
+ if (password.length < 8) {
1160
+ console.error("Error: password must be at least 8 characters.");
1161
+ process.exit(1);
1162
+ }
1163
+ confirm = await promptPasswordTTY("Confirm new password: ");
1164
+ } else {
1165
+ // Piped input — read both lines from a single readline interface so
1166
+ // closing it doesn't break the second prompt.
1167
+ const { createInterface } = await import("node:readline");
1168
+ const rl = createInterface({ input: process.stdin });
1169
+ [password, confirm] = await readTwoLines(rl);
1170
+ rl.close();
1171
+ if (!password) {
1172
+ console.error("Error: password cannot be empty.");
1173
+ process.exit(1);
1174
+ }
1175
+ if (password.length < 8) {
1176
+ console.error("Error: password must be at least 8 characters.");
1177
+ process.exit(1);
1178
+ }
1179
+ }
1180
+
1181
+ if (password !== confirm) {
1182
+ console.error("Error: passwords do not match.");
1183
+ process.exit(1);
1184
+ }
1185
+
1186
+ const salt = generateSalt();
1187
+ const hash = await hashPassword(password, salt);
1188
+
1189
+ updateEnvFile(
1190
+ { AUTH_PASSWORD_HASH: hash, AUTH_PASSWORD_SALT: salt },
1191
+ ["AUTH_PASSWORD"], // strip any plaintext leftover, just in case
1192
+ );
1193
+
1194
+ console.log("\nPassword set successfully.");
1195
+ console.log(` Stored in: ${ENV_FILE}`);
1196
+
1197
+ const pid = readPid();
1198
+ if (pid) {
1199
+ console.log(" Restart the server to apply: drawlatch restart");
1200
+ }
1201
+ }
1202
+
1203
+ function readTwoLines(rl) {
1204
+ return new Promise((resolveP) => {
1205
+ const lines = [];
1206
+ const onLine = (line) => {
1207
+ lines.push(line);
1208
+ if (lines.length >= 2) {
1209
+ rl.removeListener("line", onLine);
1210
+ resolveP(lines);
1211
+ }
1212
+ };
1213
+ rl.on("line", onLine);
1214
+ rl.once("close", () => {
1215
+ while (lines.length < 2) lines.push("");
1216
+ resolveP(lines);
1217
+ });
1218
+ });
1219
+ }
1220
+
1221
+ function promptPasswordTTY(prompt) {
1222
+ return new Promise((resolveP) => {
1223
+ process.stdout.write(prompt);
1224
+ const stdin = process.stdin;
1225
+ stdin.setRawMode(true);
1226
+ stdin.resume();
1227
+ stdin.setEncoding("utf8");
1228
+ let password = "";
1229
+ const onData = (ch) => {
1230
+ ch = ch.toString();
1231
+ if (ch === "\n" || ch === "\r" || ch === "\u0004") {
1232
+ stdin.setRawMode(false);
1233
+ stdin.pause();
1234
+ stdin.removeListener("data", onData);
1235
+ process.stdout.write("\n");
1236
+ resolveP(password);
1237
+ } else if (ch === "\u0003") {
1238
+ // Ctrl+C
1239
+ stdin.setRawMode(false);
1240
+ process.stdout.write("\n");
1241
+ process.exit(0);
1242
+ } else if (ch === "\u007F" || ch === "\b") {
1243
+ if (password.length > 0) password = password.slice(0, -1);
1244
+ } else {
1245
+ password += ch;
1246
+ }
1247
+ };
1248
+ stdin.on("data", onData);
1249
+ });
1250
+ }
1251
+
1252
+ /** Read ~/.drawlatch/.env and report whether a dashboard password hash is set. */
1253
+ function isPasswordConfigured() {
1254
+ try {
1255
+ const envVars = loadEnvFileVars();
1256
+ return !!envVars.AUTH_PASSWORD_HASH;
1257
+ } catch {
1258
+ return false;
1259
+ }
1260
+ }
1261
+
416
1262
  // ── PID utilities ─────────────────────────────────────────────────
417
1263
 
418
1264
  function isProcessAlive(pid) {
@@ -449,11 +1295,16 @@ function cleanPidFile() {
449
1295
 
450
1296
  // ── Health check utilities ────────────────────────────────────────
451
1297
 
1298
+ /** Resolve the host for client connections — 0.0.0.0 is a bind address, not connectable. */
1299
+ function connectHost(host) {
1300
+ return host === "0.0.0.0" ? "127.0.0.1" : host;
1301
+ }
1302
+
452
1303
  async function healthCheck(host, port) {
453
1304
  try {
454
1305
  const controller = new AbortController();
455
1306
  const timeout = setTimeout(() => controller.abort(), 3000);
456
- const res = await fetch(`http://${host}:${port}/health`, {
1307
+ const res = await fetch(`http://${connectHost(host)}:${port}/health`, {
457
1308
  signal: controller.signal,
458
1309
  });
459
1310
  clearTimeout(timeout);
@@ -467,7 +1318,7 @@ async function healthCheckFull(host, port) {
467
1318
  try {
468
1319
  const controller = new AbortController();
469
1320
  const timeout = setTimeout(() => controller.abort(), 3000);
470
- const res = await fetch(`http://${host}:${port}/health`, {
1321
+ const res = await fetch(`http://${connectHost(host)}:${port}/health`, {
471
1322
  signal: controller.signal,
472
1323
  });
473
1324
  clearTimeout(timeout);
@@ -515,18 +1366,18 @@ function ensureConfigDir() {
515
1366
  // ── Diagnostic utilities ──────────────────────────────────────────
516
1367
 
517
1368
  async function diagnoseStartFailure() {
518
- if (!existsSync(LOG_FILE)) return;
1369
+ if (!existsSync(LOG_FILE)) {
1370
+ console.log("\n No log file found. The server may not have started at all.");
1371
+ return;
1372
+ }
519
1373
  try {
520
1374
  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.");
1375
+ const lines = content.split("\n").filter(Boolean).slice(-15);
1376
+ if (lines.length > 0) {
1377
+ console.log("\n Recent logs:");
1378
+ for (const line of lines) {
1379
+ console.log(` ${line}`);
1380
+ }
530
1381
  }
531
1382
  } catch {
532
1383
  // Best effort
@@ -549,6 +1400,120 @@ function sleep(ms) {
549
1400
  return new Promise((r) => setTimeout(r, ms));
550
1401
  }
551
1402
 
1403
+ // ── Version check utilities ───────────────────────────────────────
1404
+
1405
+ const NPM_PACKAGE_NAME = pkgJson.name;
1406
+
1407
+ function readVersionCache() {
1408
+ try {
1409
+ if (!existsSync(VERSION_CACHE_FILE)) return null;
1410
+ const data = JSON.parse(readFileSync(VERSION_CACHE_FILE, "utf-8"));
1411
+ if (data.checkedAt && Date.now() - data.checkedAt < VERSION_CHECK_TTL_MS) {
1412
+ return data.latestVersion;
1413
+ }
1414
+ return null; // stale
1415
+ } catch {
1416
+ return null;
1417
+ }
1418
+ }
1419
+
1420
+ function writeVersionCache(latestVersion) {
1421
+ try {
1422
+ ensureConfigDir();
1423
+ writeFileSync(
1424
+ VERSION_CACHE_FILE,
1425
+ JSON.stringify({ latestVersion, checkedAt: Date.now() }) + "\n",
1426
+ { mode: 0o600 },
1427
+ );
1428
+ } catch {
1429
+ // Best effort
1430
+ }
1431
+ }
1432
+
1433
+ async function fetchLatestVersion() {
1434
+ try {
1435
+ const controller = new AbortController();
1436
+ const timeout = setTimeout(() => controller.abort(), 5000);
1437
+ const res = await fetch(
1438
+ `https://registry.npmjs.org/${NPM_PACKAGE_NAME}/latest`,
1439
+ { signal: controller.signal },
1440
+ );
1441
+ clearTimeout(timeout);
1442
+ if (!res.ok) return null;
1443
+ const data = await res.json();
1444
+ return data.version || null;
1445
+ } catch {
1446
+ return null;
1447
+ }
1448
+ }
1449
+
1450
+ function compareVersions(a, b) {
1451
+ // Returns > 0 if a > b, < 0 if a < b, 0 if equal
1452
+ // Handles pre-release: 1.0.0 > 1.0.0-alpha.1
1453
+ const parseVer = (v) => {
1454
+ const [core, pre] = v.split("-", 2);
1455
+ const parts = core.split(".").map(Number);
1456
+ return { parts, pre: pre || null };
1457
+ };
1458
+ const va = parseVer(a);
1459
+ const vb = parseVer(b);
1460
+
1461
+ // Compare core version parts
1462
+ const maxLen = Math.max(va.parts.length, vb.parts.length);
1463
+ for (let i = 0; i < maxLen; i++) {
1464
+ const pa = va.parts[i] || 0;
1465
+ const pb = vb.parts[i] || 0;
1466
+ if (pa !== pb) return pa - pb;
1467
+ }
1468
+
1469
+ // Same core: no pre-release > pre-release
1470
+ if (!va.pre && vb.pre) return 1;
1471
+ if (va.pre && !vb.pre) return -1;
1472
+ if (!va.pre && !vb.pre) return 0;
1473
+
1474
+ // Both have pre-release: compare segments
1475
+ const aParts = va.pre.split(".");
1476
+ const bParts = vb.pre.split(".");
1477
+ const preLen = Math.max(aParts.length, bParts.length);
1478
+ for (let i = 0; i < preLen; i++) {
1479
+ const sa = aParts[i];
1480
+ const sb = bParts[i];
1481
+ if (sa === undefined) return -1;
1482
+ if (sb === undefined) return 1;
1483
+ const na = Number(sa);
1484
+ const nb = Number(sb);
1485
+ const aIsNum = !isNaN(na);
1486
+ const bIsNum = !isNaN(nb);
1487
+ if (aIsNum && bIsNum) {
1488
+ if (na !== nb) return na - nb;
1489
+ } else if (aIsNum) {
1490
+ return -1; // numbers sort before strings
1491
+ } else if (bIsNum) {
1492
+ return 1;
1493
+ } else {
1494
+ if (sa < sb) return -1;
1495
+ if (sa > sb) return 1;
1496
+ }
1497
+ }
1498
+ return 0;
1499
+ }
1500
+
1501
+ async function checkForUpdate() {
1502
+ // Try cache first
1503
+ const cached = readVersionCache();
1504
+ if (cached) return cached !== VERSION && compareVersions(cached, VERSION) > 0 ? cached : null;
1505
+
1506
+ // Fetch in background-ish (awaited but with short timeout)
1507
+ const latest = await fetchLatestVersion();
1508
+ if (latest) writeVersionCache(latest);
1509
+ if (latest && latest !== VERSION && compareVersions(latest, VERSION) > 0) return latest;
1510
+ return null;
1511
+ }
1512
+
1513
+ function formatUpdateNotice(latestVersion) {
1514
+ return `\n Update available: ${VERSION} → ${latestVersion}\n Run: npm install -g ${NPM_PACKAGE_NAME}\n`;
1515
+ }
1516
+
552
1517
  // ── Help text ─────────────────────────────────────────────────────
553
1518
 
554
1519
  function printHelp() {
@@ -558,13 +1523,18 @@ drawlatch v${VERSION}
558
1523
  Usage: drawlatch [command] [options]
559
1524
 
560
1525
  Commands:
1526
+ init Set up drawlatch server (keys, config, .env)
561
1527
  start Start the remote server (background by default)
562
1528
  stop Stop the background remote server
563
1529
  restart Restart the background remote server
564
1530
  status Show server status (PID, port, uptime, health, sessions)
565
1531
  logs View and follow remote server logs
566
1532
  config Show effective configuration
1533
+ watch Watch ingestor events in real time
1534
+ doctor Validate setup and diagnose issues
567
1535
  generate-keys Generate Ed25519 + X25519 keypairs
1536
+ issue-caller Issue a caller credential bundle (for a callboard instance)
1537
+ set-password Set/change the dashboard password (alias: change-password)
568
1538
 
569
1539
  Options:
570
1540
  -h, --help Show this help message
@@ -573,15 +1543,13 @@ Options:
573
1543
  Running 'drawlatch' with no arguments shows status (if running) or this help.
574
1544
 
575
1545
  Examples:
576
- drawlatch Show status or help
1546
+ drawlatch init Set up the remote server
577
1547
  drawlatch start Start remote server in background
578
1548
  drawlatch start -f Start remote server in foreground
579
1549
  drawlatch start -f --tunnel Start with a public tunnel for webhooks
580
- drawlatch start --port 8080 Start on a custom port
1550
+ drawlatch doctor Validate full setup
581
1551
  drawlatch status Check if server is running
582
1552
  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
1553
  `);
586
1554
  }
587
1555
 
@@ -658,19 +1626,42 @@ function printLogsHelp() {
658
1626
  console.log(`
659
1627
  drawlatch logs
660
1628
 
661
- View server logs.
1629
+ View server logs (request/response audit lines are hidden by default).
662
1630
 
663
1631
  Usage: drawlatch logs [options]
664
1632
 
665
1633
  Options:
666
1634
  -n, --lines <number> Number of lines to show (default: 50)
667
- --no-follow Print lines and exit (default: follow/tail)
1635
+ --follow Follow/tail the log output (default: print and exit)
1636
+ --requests Include request/response audit lines (noisy with polling)
668
1637
  -h, --help Show this help message
669
1638
 
670
1639
  Log file: ~/.drawlatch/logs/drawlatch.log
671
1640
  `);
672
1641
  }
673
1642
 
1643
+ function printWatchHelp() {
1644
+ console.log(`
1645
+ drawlatch watch
1646
+
1647
+ Watch ingestor events in real time.
1648
+
1649
+ Usage: drawlatch watch [source] [options]
1650
+
1651
+ Arguments:
1652
+ source Filter to a specific connection (e.g., "discord-bot", "github")
1653
+
1654
+ Options:
1655
+ --full Show full event payload (default: truncate to 100 chars)
1656
+ -h, --help Show this help message
1657
+
1658
+ Examples:
1659
+ drawlatch watch Watch all events
1660
+ drawlatch watch github Watch only GitHub events
1661
+ drawlatch watch discord-bot --full Watch Discord events with full payloads
1662
+ `);
1663
+ }
1664
+
674
1665
  function printConfigHelp() {
675
1666
  console.log(`
676
1667
  drawlatch config
@@ -688,6 +1679,116 @@ server configuration including callers and connections.
688
1679
  `);
689
1680
  }
690
1681
 
1682
+ function printInitHelp() {
1683
+ console.log(`
1684
+ drawlatch init
1685
+
1686
+ Set up the drawlatch remote server. Generates server keys, creates
1687
+ config files, and scaffolds a .env template.
1688
+
1689
+ Callers are issued separately via 'drawlatch issue-caller' (or the dashboard
1690
+ Callers page) after the server is running.
1691
+
1692
+ Usage: drawlatch init [options]
1693
+
1694
+ Options:
1695
+ -h, --help Show this help message
1696
+
1697
+ All steps are idempotent — safe to re-run without overwriting existing files.
1698
+ `);
1699
+ }
1700
+
1701
+ function printDoctorHelp() {
1702
+ console.log(`
1703
+ drawlatch doctor
1704
+
1705
+ Validate your drawlatch setup and diagnose common issues.
1706
+
1707
+ Usage: drawlatch doctor [options]
1708
+
1709
+ Options:
1710
+ --full Include a live handshake test (requires server running)
1711
+ -h, --help Show this help message
1712
+
1713
+ Checks config files, keys, peer directories, secrets, and server health.
1714
+ Each failure includes the exact command to fix it.
1715
+ `);
1716
+ }
1717
+
1718
+ function printSyncHelp() {
1719
+ console.log(`
1720
+ drawlatch sync
1721
+
1722
+ Exchange keys with a callboard instance using a double-code approval flow.
1723
+
1724
+ Usage: drawlatch sync [options]
1725
+
1726
+ Options:
1727
+ --ttl <seconds> Sync session timeout (default: 300)
1728
+ -h, --help Show this help message
1729
+
1730
+ Flow:
1731
+ 1. Run 'drawlatch sync' — displays an invite code and encryption key
1732
+ 2. Give both values to the callboard operator
1733
+ 3. They enter them into callboard, which generates a confirm code
1734
+ 4. Enter the confirm code when prompted
1735
+ 5. Callboard sends the encrypted sync request
1736
+ 6. Keys are exchanged automatically
1737
+
1738
+ The server must be running (drawlatch start) before using this command.
1739
+ `);
1740
+ }
1741
+
1742
+ function printIssueCallerHelp() {
1743
+ console.log(`
1744
+ drawlatch issue-caller
1745
+
1746
+ Issue a caller credential bundle that a callboard instance imports to gain a
1747
+ caller identity. drawlatch mints the keypair, keeps only the PUBLIC key, and
1748
+ hands the private key out in the bundle (shown once — re-issue to rotate).
1749
+
1750
+ Usage: drawlatch issue-caller <alias> [options]
1751
+
1752
+ Options:
1753
+ --name <name> Human-readable display name (defaults to the alias)
1754
+ --connections <a,b,c> Comma-separated connections to authorize
1755
+ (defaults to cloning the "default" caller)
1756
+ --endpoint <url> Endpoint URL to pin in the bundle
1757
+ (defaults to this server's host:port)
1758
+ --passphrase Prompt for a passphrase and scrypt+AES-256-GCM wrap
1759
+ the private keys in the bundle (for untrusted transport)
1760
+ -o, --output <file> Write the bundle to a file (0600) instead of stdout
1761
+ --into <keysDir> Same-host: write the UNPACKED key files directly into a
1762
+ co-located callboard's keys dir (no bundle file)
1763
+ -h, --help Show this help message
1764
+
1765
+ Examples:
1766
+ drawlatch issue-caller callboard-prod -o callboard-prod.drawlatch-caller.json
1767
+ drawlatch issue-caller callboard-prod --passphrase -o bundle.json
1768
+ drawlatch issue-caller callboard-local --into ~/.callboard/keys
1769
+ `);
1770
+ }
1771
+
1772
+ function printSetPasswordHelp(cmd = "set-password") {
1773
+ console.log(`
1774
+ drawlatch ${cmd}
1775
+
1776
+ Set or change the password that gates the admin dashboard and /api/admin/*.
1777
+
1778
+ Usage: drawlatch ${cmd} [options]
1779
+
1780
+ Options:
1781
+ -h, --help Show this help message
1782
+
1783
+ Prompts for a new password (hidden in a TTY; reads two lines when piped),
1784
+ hashes it with scrypt, and stores AUTH_PASSWORD_HASH / AUTH_PASSWORD_SALT in
1785
+ ${ENV_FILE}.
1786
+
1787
+ The password must be at least 8 characters. 'change-password' is an alias.
1788
+ Restart the server afterwards (drawlatch restart) to apply the new password.
1789
+ `);
1790
+ }
1791
+
691
1792
  function printGenerateKeysHelp() {
692
1793
  console.log(`
693
1794
  drawlatch generate-keys
@@ -697,10 +1798,11 @@ Generate Ed25519 + X25519 keypairs for authentication and encryption.
697
1798
  Usage: drawlatch generate-keys <subcommand> [options]
698
1799
 
699
1800
  Subcommands:
700
- local [alias] Generate MCP proxy (local) keypair
1801
+ caller [alias] Generate caller keypair
701
1802
  Alias defaults to "default" if omitted.
702
- Keys are stored in keys/local/<alias>/
703
- remote Generate remote server keypair
1803
+ Keys are stored in keys/callers/<alias>/
1804
+ server Generate server keypair
1805
+ Keys are stored in keys/server/
704
1806
  --dir <path> Generate keypair in a custom directory
705
1807
  show <path> Show fingerprint of an existing keypair
706
1808