@wolpertingerlabs/drawlatch 1.0.0-alpha.2 → 1.0.0-alpha.29

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 (119) hide show
  1. package/CONNECTIONS.md +3 -0
  2. package/README.md +395 -465
  3. package/bin/drawlatch.js +1018 -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 +37 -0
  39. package/dist/remote/admin-mutations.js +251 -0
  40. package/dist/remote/admin-types.d.ts +149 -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 +316 -0
  44. package/dist/remote/caller-bootstrap.d.ts +71 -0
  45. package/dist/remote/caller-bootstrap.js +141 -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 +681 -195
  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 +53 -0
  83. package/dist/remote/tunnel.js +149 -0
  84. package/dist/shared/config.d.ts +97 -25
  85. package/dist/shared/config.js +48 -35
  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 +67 -0
  91. package/dist/shared/crypto/key-manager.js +152 -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/handshake.js +14 -1
  99. package/dist/shared/protocol/index.d.ts +2 -0
  100. package/dist/shared/protocol/index.js +2 -0
  101. package/dist/shared/protocol/sync-client.d.ts +52 -0
  102. package/dist/shared/protocol/sync-client.js +99 -0
  103. package/dist/shared/protocol/sync.d.ts +71 -0
  104. package/dist/shared/protocol/sync.js +176 -0
  105. package/frontend/dist/assets/index-D4k5QKBP.css +1 -0
  106. package/frontend/dist/assets/index-IzwnSOmu.js +267 -0
  107. package/frontend/dist/index.html +15 -0
  108. package/package.json +66 -13
  109. package/dist/connections/discord-bot.json +0 -24
  110. package/dist/connections/github.json +0 -25
  111. package/dist/connections/linear.json +0 -29
  112. package/dist/connections/mastodon.json +0 -25
  113. package/dist/connections/notion.json +0 -33
  114. package/dist/connections/reddit.json +0 -28
  115. package/dist/connections/slack.json +0 -23
  116. package/dist/connections/stripe.json +0 -25
  117. package/dist/connections/telegram.json +0 -26
  118. package/dist/connections/trello.json +0 -25
  119. 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(
@@ -57,11 +74,15 @@ try {
57
74
  help: { type: "boolean", short: "h", default: false },
58
75
  version: { type: "boolean", short: "v", default: false },
59
76
  foreground: { type: "boolean", short: "f", default: false },
77
+ tunnel: { type: "boolean", short: "t", default: false },
60
78
  port: { type: "string" },
61
79
  host: { type: "string" },
62
80
  lines: { type: "string", short: "n", default: "50" },
63
- follow: { type: "boolean", default: true },
81
+ follow: { type: "boolean", default: false },
64
82
  path: { type: "boolean", default: false },
83
+ full: { type: "boolean", default: false },
84
+ requests: { type: "boolean", default: false },
85
+ ttl: { type: "string", default: "300" },
65
86
  },
66
87
  strict: false,
67
88
  allowPositionals: true,
@@ -71,6 +92,11 @@ try {
71
92
  process.exit(1);
72
93
  }
73
94
 
95
+ // ── Kick off version check early (non-blocking) ──────────────────
96
+ const updateCheckPromise = (subcommand === null || subcommand === "help" || (values.help && !subcommand))
97
+ ? checkForUpdate()
98
+ : Promise.resolve(null);
99
+
74
100
  // ── Dispatch ──────────────────────────────────────────────────────
75
101
  if (values.version) {
76
102
  console.log(VERSION);
@@ -78,6 +104,11 @@ if (values.version) {
78
104
  }
79
105
  if (values.help && !subcommand) {
80
106
  printHelp();
107
+ const latestVersion = await Promise.race([
108
+ updateCheckPromise,
109
+ new Promise((r) => setTimeout(() => r(null), 100)),
110
+ ]);
111
+ if (latestVersion) console.log(formatUpdateNotice(latestVersion));
81
112
  process.exit(0);
82
113
  }
83
114
 
@@ -85,6 +116,13 @@ switch (subcommand) {
85
116
  case null:
86
117
  await cmdDefault();
87
118
  break;
119
+ case "init":
120
+ if (values.help) {
121
+ printInitHelp();
122
+ } else {
123
+ await cmdInit();
124
+ }
125
+ break;
88
126
  case "start":
89
127
  if (values.help) {
90
128
  printStartHelp();
@@ -134,8 +172,44 @@ switch (subcommand) {
134
172
  await cmdGenerateKeys();
135
173
  }
136
174
  break;
175
+ case "doctor":
176
+ if (values.help) {
177
+ printDoctorHelp();
178
+ } else {
179
+ await cmdDoctor();
180
+ }
181
+ break;
182
+ case "sync":
183
+ if (values.help) {
184
+ printSyncHelp();
185
+ } else {
186
+ await cmdSync();
187
+ }
188
+ break;
189
+ case "set-password":
190
+ case "change-password":
191
+ if (values.help) {
192
+ printSetPasswordHelp(subcommand);
193
+ } else {
194
+ await cmdSetPassword();
195
+ }
196
+ break;
197
+ case "watch":
198
+ if (values.help) {
199
+ printWatchHelp();
200
+ } else {
201
+ await cmdWatch();
202
+ }
203
+ break;
137
204
  case "help":
138
205
  printHelp();
206
+ {
207
+ const latestVersion = await Promise.race([
208
+ updateCheckPromise,
209
+ new Promise((r) => setTimeout(() => r(null), 100)),
210
+ ]);
211
+ if (latestVersion) console.log(formatUpdateNotice(latestVersion));
212
+ }
139
213
  break;
140
214
  default:
141
215
  console.error(`Unknown command: ${subcommand}\n`);
@@ -153,8 +227,96 @@ async function cmdDefault() {
153
227
  console.log("Drawlatch remote server is not running.\n");
154
228
  printHelp();
155
229
  }
230
+ // Show update notice only if the check already resolved (don't block on network)
231
+ const latestVersion = await Promise.race([
232
+ updateCheckPromise,
233
+ new Promise((r) => setTimeout(() => r(null), 100)),
234
+ ]);
235
+ if (latestVersion) console.log(formatUpdateNotice(latestVersion));
236
+ }
237
+
238
+ async function cmdInit() {
239
+ console.log(`\nDrawlatch Setup`);
240
+ console.log(`===============\n`);
241
+
242
+ const steps = [];
243
+
244
+ // Step 1: Ensure config directory
245
+ ensureConfigDir();
246
+ steps.push(`Config directory: ${CONFIG_DIR}`);
247
+
248
+ // Step 2: Generate server keypair
249
+ const serverKeysDir = getServerKeysDir();
250
+ if (existsSync(join(serverKeysDir, "signing.key.pem"))) {
251
+ const existing = loadKeyBundle(serverKeysDir);
252
+ const fp = fingerprint(extractPublicKeys(existing));
253
+ steps.push(`Server keys: already exist (${fp})`);
254
+ } else {
255
+ const bundle = generateKeyBundle();
256
+ saveKeyBundle(bundle, serverKeysDir);
257
+ const fp = fingerprint(extractPublicKeys(bundle));
258
+ steps.push(`Server keys: CREATED (${fp})`);
259
+ }
260
+
261
+ // Step 3: Scaffold proxy.config.json
262
+ const proxyConfigPath = getProxyConfigPath();
263
+ if (existsSync(proxyConfigPath)) {
264
+ steps.push(`Proxy config: already exists`);
265
+ } else {
266
+ const proxyConfig = {
267
+ remoteUrl: "http://127.0.0.1:9999",
268
+ connectTimeout: 10000,
269
+ requestTimeout: 300000,
270
+ };
271
+ writeFileSync(proxyConfigPath, JSON.stringify(proxyConfig, null, 2) + "\n", { mode: 0o600 });
272
+ steps.push(`Proxy config: CREATED`);
273
+ }
274
+
275
+ // Step 4: Scaffold remote.config.json
276
+ const remoteConfigPath = getRemoteConfigPath();
277
+ if (existsSync(remoteConfigPath)) {
278
+ steps.push(`Remote config: already exists`);
279
+ } else {
280
+ const remoteConfig = {
281
+ host: "0.0.0.0",
282
+ port: 9999,
283
+ rateLimitPerMinute: 60,
284
+ callers: {},
285
+ };
286
+ writeFileSync(remoteConfigPath, JSON.stringify(remoteConfig, null, 2) + "\n", { mode: 0o600 });
287
+ steps.push(`Remote config: CREATED`);
288
+ }
289
+
290
+ // Step 5: Scaffold .env file
291
+ if (existsSync(ENV_FILE)) {
292
+ steps.push(`.env file: already exists`);
293
+ } else {
294
+ const envLines = [
295
+ "# Drawlatch environment secrets",
296
+ "# Set tokens for your enabled connections",
297
+ "# Secrets are prefixed per caller (e.g., DEFAULT_GITHUB_TOKEN)",
298
+ "",
299
+ ];
300
+ writeFileSync(ENV_FILE, envLines.join("\n") + "\n", { mode: 0o600 });
301
+ steps.push(`.env file: CREATED`);
302
+ }
303
+
304
+ // Print summary
305
+ for (const step of steps) {
306
+ console.log(` ${step}`);
307
+ }
308
+
309
+ console.log(`\nSetup complete! Next steps:\n`);
310
+ console.log(` 1. Start the remote server:`);
311
+ console.log(` drawlatch start\n`);
312
+ console.log(` 2. Add callers via key sync:`);
313
+ console.log(` drawlatch sync\n`);
314
+ console.log(` 3. Verify your setup:`);
315
+ console.log(` drawlatch doctor\n`);
156
316
  }
157
317
 
318
+
319
+
158
320
  async function cmdStart() {
159
321
  if (values.foreground) return cmdStartForeground();
160
322
 
@@ -182,6 +344,7 @@ async function cmdStart() {
182
344
  NODE_ENV: "production",
183
345
  ...(values.port ? { DRAWLATCH_PORT: String(port) } : {}),
184
346
  ...(values.host ? { DRAWLATCH_HOST: host } : {}),
347
+ ...(values.tunnel ? { DRAWLATCH_TUNNEL: "1" } : {}),
185
348
  },
186
349
  cwd: PKG_ROOT,
187
350
  });
@@ -195,12 +358,30 @@ async function cmdStart() {
195
358
  if (healthy) {
196
359
  console.log(`\nRemote server is running (PID ${child.pid}).`);
197
360
  console.log(` Listening: ${host}:${port}`);
361
+ if (values.tunnel) {
362
+ // The tunnel starts asynchronously after the server is healthy —
363
+ // poll the health endpoint until the tunnel URL appears (up to 20s).
364
+ console.log(` Tunnel: waiting for cloudflared...`);
365
+ const tunnelUrl = await waitForTunnelUrl(host, port, 20000);
366
+ if (tunnelUrl) {
367
+ console.log(` Tunnel: ${tunnelUrl}`);
368
+ console.log(` Webhooks: ${tunnelUrl}/webhooks/<path>`);
369
+ } else {
370
+ console.log(` Tunnel: not available (check logs: drawlatch logs)`);
371
+ }
372
+ }
198
373
  console.log(` Logs: drawlatch logs`);
199
374
  } else {
200
- console.log(
201
- `\nServer started (PID ${child.pid}) but health check did not pass.`,
202
- );
203
- console.log(` Check logs: drawlatch logs`);
375
+ const stillAlive = isProcessAlive(child.pid);
376
+ if (stillAlive) {
377
+ console.log(
378
+ `\nServer started (PID ${child.pid}) but health check did not pass within 5s.`,
379
+ );
380
+ console.log(` The server process is still running — it may need more time.`);
381
+ } else {
382
+ console.log(`\nServer process (PID ${child.pid}) exited before becoming healthy.`);
383
+ cleanPidFile();
384
+ }
204
385
  await diagnoseStartFailure();
205
386
  }
206
387
  }
@@ -209,6 +390,7 @@ async function cmdStartForeground() {
209
390
  process.env.NODE_ENV = process.env.NODE_ENV || "production";
210
391
  if (values.port) process.env.DRAWLATCH_PORT = values.port;
211
392
  if (values.host) process.env.DRAWLATCH_HOST = values.host;
393
+ if (values.tunnel) process.env.DRAWLATCH_TUNNEL = "1";
212
394
 
213
395
  ensureConfigDir();
214
396
 
@@ -250,6 +432,17 @@ async function cmdStop() {
250
432
  async function cmdRestart() {
251
433
  const pid = readPid();
252
434
  if (pid) {
435
+ // If the previous server had an active tunnel, carry the flag forward
436
+ // so the restarted server also starts a tunnel (unless --tunnel is
437
+ // already set or the user explicitly omitted it).
438
+ if (!values.tunnel) {
439
+ const config = loadRemoteConfig();
440
+ const prevHealth = await healthCheckFull(config.host, config.port);
441
+ if (prevHealth?.tunnelUrl) {
442
+ console.log("Previous server had an active tunnel — re-enabling --tunnel.");
443
+ values.tunnel = true;
444
+ }
445
+ }
253
446
  await cmdStop();
254
447
  }
255
448
  await cmdStart();
@@ -279,12 +472,19 @@ async function cmdStatus() {
279
472
  console.log("Drawlatch remote server is running.");
280
473
  console.log(` PID: ${pid}`);
281
474
  console.log(` Listening: ${host}:${port}`);
475
+ console.log(` Dashboard: http://${connectHost(host)}:${port}/`);
476
+ console.log(
477
+ ` Password: ${isPasswordConfigured() ? "configured" : "not set (run: drawlatch set-password)"}`,
478
+ );
282
479
  console.log(` Uptime: ${uptime}`);
283
480
  console.log(
284
481
  ` Health: ${healthData ? "healthy" : "unhealthy (not responding)"}`,
285
482
  );
286
483
  if (healthData) {
287
484
  console.log(` Active sessions: ${healthData.activeSessions}`);
485
+ if (healthData.tunnelUrl) {
486
+ console.log(` Tunnel: ${healthData.tunnelUrl}`);
487
+ }
288
488
  }
289
489
  }
290
490
 
@@ -297,39 +497,154 @@ async function cmdLogs() {
297
497
 
298
498
  const lines = parseInt(values.lines, 10) || 50;
299
499
  const follow = values.follow;
500
+ const showRequests = values.requests;
300
501
 
301
502
  const tailArgs = follow
302
503
  ? ["-n", String(lines), "-f", LOG_FILE]
303
504
  : ["-n", String(lines), LOG_FILE];
304
505
 
305
- const tail = spawn("tail", tailArgs, { stdio: "inherit" });
506
+ if (showRequests) {
507
+ // Show everything — pipe directly to stdout
508
+ const tail = spawn("tail", tailArgs, { stdio: "inherit" });
306
509
 
307
- tail.on("error", () => {
308
- // Fallback: read last N lines with Node.js if tail is not available
309
- try {
310
- const content = readFileSync(LOG_FILE, "utf-8");
311
- const allLines = content.split("\n");
312
- const lastLines = allLines.slice(-lines).join("\n");
313
- console.log(lastLines);
314
- if (follow) {
315
- console.log(
316
- "\n(Live following not available \u2014 'tail' command not found)",
317
- );
318
- }
319
- } catch (err) {
320
- console.error(`Error reading log file: ${err.message}`);
321
- process.exit(1);
510
+ tail.on("error", () => logsFallback(lines, follow, null));
511
+
512
+ process.on("SIGINT", () => {
513
+ tail.kill();
514
+ process.exit(0);
515
+ });
516
+
517
+ await new Promise((res) => tail.on("close", res));
518
+ } else {
519
+ // Filter out [audit] lines (request/response noise from poll_events etc.)
520
+ const tail = spawn("tail", tailArgs, { stdio: ["ignore", "pipe", "inherit"] });
521
+ const grepProc = spawn("grep", ["-v", "^\\[audit\\]"], {
522
+ stdio: [tail.stdout, "inherit", "inherit"],
523
+ });
524
+
525
+ tail.on("error", () => logsFallback(lines, follow, "[audit]"));
526
+
527
+ process.on("SIGINT", () => {
528
+ tail.kill();
529
+ grepProc.kill();
530
+ process.exit(0);
531
+ });
532
+
533
+ await new Promise((res) => grepProc.on("close", res));
534
+ }
535
+ }
536
+
537
+ function logsFallback(lines, follow, filterPrefix) {
538
+ try {
539
+ const content = readFileSync(LOG_FILE, "utf-8");
540
+ let allLines = content.split("\n");
541
+ if (filterPrefix) {
542
+ allLines = allLines.filter((l) => !l.startsWith(filterPrefix));
322
543
  }
323
- });
544
+ const lastLines = allLines.slice(-lines).join("\n");
545
+ console.log(lastLines);
546
+ if (follow) {
547
+ console.log(
548
+ "\n(Live following not available \u2014 'tail' command not found)",
549
+ );
550
+ }
551
+ } catch (err) {
552
+ console.error(`Error reading log file: ${err.message}`);
553
+ process.exit(1);
554
+ }
555
+ }
556
+
557
+ async function cmdWatch() {
558
+ const config = loadRemoteConfig();
559
+ const port = config.port;
560
+ const host = config.host;
561
+
562
+ // Verify the server is running
563
+ const healthy = await healthCheck(host, port);
564
+ if (!healthy) {
565
+ console.error("Remote server is not running. Start it first:");
566
+ console.error(" drawlatch start");
567
+ process.exit(1);
568
+ }
569
+
570
+ const showFull = values.full;
571
+ const sourceFilter = positionals[0] || null;
324
572
 
325
- // Forward SIGINT to cleanly exit
573
+ console.log("Watching for events" + (sourceFilter ? ` from "${sourceFilter}"` : "") + "...");
574
+ console.log("Press Ctrl+C to stop.\n");
575
+
576
+ const controller = new AbortController();
326
577
  process.on("SIGINT", () => {
327
- tail.kill();
578
+ controller.abort();
328
579
  process.exit(0);
329
580
  });
330
581
 
331
- // Wait for tail to exit (when using --no-follow)
332
- await new Promise((res) => tail.on("close", res));
582
+ try {
583
+ const res = await fetch(
584
+ `http://${connectHost(host)}:${port}/events/stream`,
585
+ { signal: controller.signal },
586
+ );
587
+
588
+ if (!res.ok) {
589
+ console.error(`Failed to connect to event stream: HTTP ${res.status}`);
590
+ process.exit(1);
591
+ }
592
+
593
+ const decoder = new TextDecoder();
594
+ let buffer = "";
595
+
596
+ for await (const chunk of res.body) {
597
+ buffer += decoder.decode(chunk, { stream: true });
598
+
599
+ // Parse SSE lines
600
+ let newlineIdx;
601
+ while ((newlineIdx = buffer.indexOf("\n\n")) !== -1) {
602
+ const message = buffer.slice(0, newlineIdx);
603
+ buffer = buffer.slice(newlineIdx + 2);
604
+
605
+ for (const line of message.split("\n")) {
606
+ if (!line.startsWith("data: ")) continue;
607
+
608
+ try {
609
+ const event = JSON.parse(line.slice(6));
610
+
611
+ // Apply source filter if set
612
+ if (sourceFilter && event.source !== sourceFilter) continue;
613
+
614
+ const time = new Date(event.receivedAt).toLocaleTimeString();
615
+ const source = event.source;
616
+ const instance = event.instanceId ? `:${event.instanceId}` : "";
617
+ const caller = event.callerAlias || "?";
618
+ const eventType = event.eventType;
619
+
620
+ const dataStr = JSON.stringify(event.data);
621
+
622
+ if (showFull) {
623
+ console.log(
624
+ `\x1b[2m${time}\x1b[0m \x1b[36m${source}${instance}\x1b[0m \x1b[33m${eventType}\x1b[0m \x1b[2m(${caller})\x1b[0m`
625
+ );
626
+ console.log(dataStr);
627
+ console.log("");
628
+ } else {
629
+ const preview =
630
+ dataStr.length > 100
631
+ ? dataStr.slice(0, 100) + "…"
632
+ : dataStr;
633
+ console.log(
634
+ `\x1b[2m${time}\x1b[0m \x1b[36m${source}${instance}\x1b[0m \x1b[33m${eventType}\x1b[0m \x1b[2m(${caller})\x1b[0m ${preview}`
635
+ );
636
+ }
637
+ } catch {
638
+ // Skip malformed SSE data
639
+ }
640
+ }
641
+ }
642
+ }
643
+ } catch (err) {
644
+ if (err.name === "AbortError") return;
645
+ console.error(`Event stream error: ${err.message}`);
646
+ process.exit(1);
647
+ }
333
648
  }
334
649
 
335
650
  function cmdConfig() {
@@ -349,14 +664,51 @@ function cmdConfig() {
349
664
  console.log(` Host: ${config.host}`);
350
665
  console.log(` Port: ${config.port}`);
351
666
  console.log(` Rate limit: ${config.rateLimitPerMinute} req/min`);
352
- console.log(` Local keys dir: ${config.localKeysDir}`);
667
+ console.log(` Server keys dir: ${getServerKeysDir()}`);
668
+
669
+ // Show key fingerprints if keys exist
670
+ const serverKeysPath = getServerKeysDir();
671
+ if (existsSync(join(serverKeysPath, "signing.key.pem"))) {
672
+ try {
673
+ const serverKeys = loadKeyBundle(serverKeysPath);
674
+ console.log(` Server key fp: ${fingerprint(extractPublicKeys(serverKeys))}`);
675
+ } catch { /* skip */ }
676
+ }
353
677
 
354
678
  const callerEntries = Object.entries(config.callers || {});
355
679
  console.log(` Callers: ${callerEntries.length}`);
680
+
681
+ // Build template map for secret status checking
682
+ const templates = listConnectionTemplates();
683
+ const templateMap = new Map(templates.map((t) => [t.alias, t]));
684
+
685
+ // Load .env vars into process.env so isSecretSetForCaller works.
686
+ // Force-set to override empty shell env vars.
687
+ const envVars = loadEnvFileVars();
688
+ for (const [k, v] of Object.entries(envVars)) {
689
+ process.env[k] = v;
690
+ }
691
+
356
692
  for (const [alias, caller] of callerEntries) {
357
693
  console.log(
358
694
  ` ${alias}: ${caller.connections ? caller.connections.length : 0} connection(s)`,
359
695
  );
696
+ for (const connName of caller.connections || []) {
697
+ const tpl = templateMap.get(connName);
698
+ if (!tpl) {
699
+ console.log(` ${connName} (custom connector)`);
700
+ continue;
701
+ }
702
+ const secretStatuses = tpl.requiredSecrets.map((s) => {
703
+ const set = isSecretSetForCaller(s, alias, caller.env);
704
+ return `${s}: ${set ? "SET" : "NOT SET"}`;
705
+ });
706
+ if (secretStatuses.length > 0) {
707
+ console.log(` ${connName}: ${secretStatuses.join(", ")}`);
708
+ } else {
709
+ console.log(` ${connName}: (no secrets required)`);
710
+ }
711
+ }
360
712
  }
361
713
 
362
714
  console.log(
@@ -384,6 +736,385 @@ async function cmdGenerateKeys() {
384
736
  process.exit(child.exitCode ?? 0);
385
737
  }
386
738
 
739
+
740
+ async function cmdDoctor() {
741
+ console.log(`\nDrawlatch Setup Check`);
742
+ console.log(`=====================\n`);
743
+
744
+ let passed = 0;
745
+ let failed = 0;
746
+
747
+ function check(label, ok, fix) {
748
+ const padded = label.padEnd(50);
749
+ if (ok) {
750
+ console.log(` ${padded} PASS`);
751
+ passed++;
752
+ } else {
753
+ console.log(` ${padded} FAIL`);
754
+ if (fix) console.log(` -> ${fix}`);
755
+ failed++;
756
+ }
757
+ return ok;
758
+ }
759
+
760
+ // Load .env for secret checks
761
+ const envVars = loadEnvFileVars();
762
+ for (const [k, v] of Object.entries(envVars)) {
763
+ process.env[k] = v;
764
+ }
765
+
766
+ // 1. Config directory
767
+ check("Config directory", existsSync(CONFIG_DIR), "Run: drawlatch init");
768
+
769
+ // 2. Remote config
770
+ const remoteConfigPath = getRemoteConfigPath();
771
+ const hasRemoteConfig = existsSync(remoteConfigPath);
772
+ check("Remote config (remote.config.json)", hasRemoteConfig, "Run: drawlatch init");
773
+
774
+ // 3. Proxy config
775
+ const proxyConfigPath = getProxyConfigPath();
776
+ const hasProxyConfig = existsSync(proxyConfigPath);
777
+ check("Proxy config (proxy.config.json)", hasProxyConfig, "Run: drawlatch init");
778
+
779
+ // 4. Server keys
780
+ const serverKeysPath = getServerKeysDir();
781
+ const hasServerKeys = existsSync(join(serverKeysPath, "signing.key.pem")) &&
782
+ existsSync(join(serverKeysPath, "exchange.key.pem")) &&
783
+ existsSync(join(serverKeysPath, "signing.pub.pem")) &&
784
+ existsSync(join(serverKeysPath, "exchange.pub.pem"));
785
+ check("Server keys (keys/server/)", hasServerKeys, "Run: drawlatch generate-keys server");
786
+
787
+ if (hasServerKeys) {
788
+ try {
789
+ const sk = loadKeyBundle(serverKeysPath);
790
+ console.log(` Fingerprint: ${fingerprint(extractPublicKeys(sk))}`);
791
+ } catch { /* skip */ }
792
+ }
793
+
794
+ // 5. Caller keys — check each caller from config
795
+ if (hasRemoteConfig) {
796
+ try {
797
+ const config = loadRemoteConfig();
798
+ const callersDir = getCallerKeysDir();
799
+ for (const [alias] of Object.entries(config.callers)) {
800
+ const callerDir = join(callersDir, alias);
801
+ const hasCallerKeys = existsSync(join(callerDir, "signing.pub.pem")) &&
802
+ existsSync(join(callerDir, "exchange.pub.pem"));
803
+ check(`Caller keys for "${alias}" (keys/callers/${alias}/)`, hasCallerKeys, `Run: drawlatch generate-keys caller ${alias} (or sync)`);
804
+
805
+ if (hasCallerKeys) {
806
+ try {
807
+ const pk = loadPublicKeys(callerDir);
808
+ console.log(` Fingerprint: ${fingerprint(pk)}`);
809
+ } catch { /* skip */ }
810
+ }
811
+ }
812
+ } catch { /* skip */ }
813
+ }
814
+
815
+ // 8. .env file
816
+ check("Environment file (.env)", existsSync(ENV_FILE), "Run: drawlatch init");
817
+
818
+ // 9. Required secrets for each caller's connections
819
+ if (hasRemoteConfig) {
820
+ try {
821
+ const config = loadRemoteConfig();
822
+ const templates = listConnectionTemplates();
823
+ const templateMap = new Map(templates.map((t) => [t.alias, t]));
824
+
825
+ for (const [callerAlias, caller] of Object.entries(config.callers)) {
826
+ for (const connName of caller.connections) {
827
+ const tpl = templateMap.get(connName);
828
+ if (!tpl) continue;
829
+ for (const secret of tpl.requiredSecrets) {
830
+ const isSet = isSecretSetForCaller(secret, callerAlias, caller.env);
831
+ check(` ${callerAlias}/${connName}: ${secret}`, isSet, `Set ${secret} in ${ENV_FILE}`);
832
+ }
833
+ }
834
+ }
835
+ } catch { /* skip */ }
836
+ }
837
+
838
+ // 10. Remote server health check
839
+ if (hasRemoteConfig) {
840
+ try {
841
+ const config = loadRemoteConfig();
842
+ const healthy = await healthCheck(config.host, config.port);
843
+ check(`Remote server health (${config.host}:${config.port})`, healthy, "Run: drawlatch start");
844
+ } catch {
845
+ check("Remote server health", false, "Run: drawlatch start");
846
+ }
847
+ }
848
+
849
+ // Summary
850
+ const total = passed + failed;
851
+ console.log(`\nResult: ${passed}/${total} checks passed`);
852
+ if (failed > 0) {
853
+ console.log(`Fix the ${failed} failing check(s) above and re-run: drawlatch doctor`);
854
+ } else {
855
+ console.log(`All checks passed! Your setup is ready.`);
856
+ }
857
+ console.log();
858
+
859
+ process.exit(failed > 0 ? 1 : 0);
860
+ }
861
+
862
+ async function cmdSync() {
863
+ const config = loadRemoteConfig();
864
+ const port = config.port;
865
+ const host = config.host;
866
+ const ttlSeconds = parseInt(values.ttl, 10) || 300;
867
+ const ttlMs = ttlSeconds * 1000;
868
+
869
+ // Check that the server is running
870
+ const healthy = await healthCheck(host, port);
871
+ if (!healthy) {
872
+ console.error(
873
+ "Error: Drawlatch remote server is not running.\n" +
874
+ " Start it first: drawlatch start\n",
875
+ );
876
+ process.exit(1);
877
+ }
878
+
879
+ // Import sync helpers from compiled drawlatch code
880
+ const { generateSyncCode, generateSyncEncryptionKey } = await import(
881
+ join(PKG_ROOT, "dist/shared/protocol/sync.js")
882
+ );
883
+
884
+ const inviteCode = generateSyncCode();
885
+ const encryptionKey = generateSyncEncryptionKey();
886
+
887
+ console.log("\nStarting key exchange...\n");
888
+ console.log(` Invite code: ${inviteCode}`);
889
+ console.log(` Encryption key: ${encryptionKey}`);
890
+ console.log(
891
+ "\nGive both values to the callboard operator.",
892
+ );
893
+ console.log("They will provide a confirm code.\n");
894
+
895
+ // Prompt for confirm code
896
+ const confirmCode = await promptInput("Enter confirm code: ");
897
+ if (!confirmCode.trim()) {
898
+ console.error("No confirm code entered. Aborting.");
899
+ process.exit(1);
900
+ }
901
+
902
+ // Open the sync session on the running server
903
+ try {
904
+ const controller = new AbortController();
905
+ const timeout = setTimeout(() => controller.abort(), 5000);
906
+ const res = await fetch(`http://${host}:${port}/sync/listen`, {
907
+ method: "POST",
908
+ headers: { "Content-Type": "application/json" },
909
+ body: JSON.stringify({
910
+ inviteCode,
911
+ confirmCode: confirmCode.trim(),
912
+ encryptionKey,
913
+ ttlMs,
914
+ }),
915
+ signal: controller.signal,
916
+ });
917
+ clearTimeout(timeout);
918
+
919
+ if (!res.ok) {
920
+ const body = await res.json().catch(() => ({}));
921
+ console.error(`Failed to open sync session: ${body.error || res.statusText}`);
922
+ process.exit(1);
923
+ }
924
+ } catch (err) {
925
+ console.error(`Failed to reach server: ${err.message}`);
926
+ process.exit(1);
927
+ }
928
+
929
+ console.log(`\nWaiting for sync from callboard (timeout: ${ttlSeconds}s)...`);
930
+
931
+ // Poll for completion
932
+ const start = Date.now();
933
+ while (Date.now() - start < ttlMs) {
934
+ await sleep(1000);
935
+
936
+ try {
937
+ const controller = new AbortController();
938
+ const timeout = setTimeout(() => controller.abort(), 3000);
939
+ const res = await fetch(`http://${host}:${port}/sync/status`, {
940
+ signal: controller.signal,
941
+ });
942
+ clearTimeout(timeout);
943
+
944
+ if (!res.ok) continue;
945
+ const status = await res.json();
946
+
947
+ if (status.completed) {
948
+ console.log(`\nSync complete!`);
949
+ console.log(` Caller alias: ${status.callerAlias}`);
950
+ console.log(` Fingerprint: ${status.fingerprint}`);
951
+ console.log(
952
+ ` Keys saved to: ${join(CONFIG_DIR, "keys", "callers", status.callerAlias)}/`,
953
+ );
954
+ console.log(`\nThe caller can now connect (no server restart needed).`);
955
+ console.log(`\nTo grant API access, add connections in ${join(CONFIG_DIR, "remote.config.json")}:`);
956
+ console.log(` "callers": {`);
957
+ console.log(` "${status.callerAlias}": {`);
958
+ console.log(` "connections": ["github", "slack", ...]`);
959
+ console.log(` }`);
960
+ console.log(` }`);
961
+ console.log(`\nThen set the required secrets in ${ENV_FILE}`);
962
+ console.log();
963
+ process.exit(0);
964
+ }
965
+
966
+ if (!status.active) {
967
+ console.error("\nSync session expired or was cancelled.");
968
+ process.exit(1);
969
+ }
970
+ } catch {
971
+ // Transient fetch error, keep polling
972
+ }
973
+ }
974
+
975
+ console.error("\nSync session timed out.");
976
+ process.exit(1);
977
+ }
978
+
979
+ async function promptInput(prompt) {
980
+ const { createInterface } = await import("node:readline");
981
+ return new Promise((resolve) => {
982
+ const rl = createInterface({
983
+ input: process.stdin,
984
+ output: process.stdout,
985
+ });
986
+ rl.question(prompt, (answer) => {
987
+ rl.close();
988
+ resolve(answer);
989
+ });
990
+ });
991
+ }
992
+
993
+ // ── set-password / change-password ────────────────────────────────
994
+ //
995
+ // Sets the dashboard/admin password. The scrypt hash + salt are written to
996
+ // ~/.drawlatch/.env (AUTH_PASSWORD_HASH / AUTH_PASSWORD_SALT) using the same
997
+ // env-writer the daemon's change-password handler uses, so both stay in sync.
998
+ async function cmdSetPassword() {
999
+ ensureConfigDir();
1000
+
1001
+ const { hashPassword, generateSalt } = await import(
1002
+ join(PKG_ROOT, "dist/auth/password.js")
1003
+ );
1004
+ const { updateEnvFile } = await import(
1005
+ join(PKG_ROOT, "dist/auth/env-writer.js")
1006
+ );
1007
+
1008
+ let password, confirm;
1009
+ if (process.stdin.isTTY) {
1010
+ password = await promptPasswordTTY("Enter new password: ");
1011
+ if (!password) {
1012
+ console.error("Error: password cannot be empty.");
1013
+ process.exit(1);
1014
+ }
1015
+ if (password.length < 8) {
1016
+ console.error("Error: password must be at least 8 characters.");
1017
+ process.exit(1);
1018
+ }
1019
+ confirm = await promptPasswordTTY("Confirm new password: ");
1020
+ } else {
1021
+ // Piped input — read both lines from a single readline interface so
1022
+ // closing it doesn't break the second prompt.
1023
+ const { createInterface } = await import("node:readline");
1024
+ const rl = createInterface({ input: process.stdin });
1025
+ [password, confirm] = await readTwoLines(rl);
1026
+ rl.close();
1027
+ if (!password) {
1028
+ console.error("Error: password cannot be empty.");
1029
+ process.exit(1);
1030
+ }
1031
+ if (password.length < 8) {
1032
+ console.error("Error: password must be at least 8 characters.");
1033
+ process.exit(1);
1034
+ }
1035
+ }
1036
+
1037
+ if (password !== confirm) {
1038
+ console.error("Error: passwords do not match.");
1039
+ process.exit(1);
1040
+ }
1041
+
1042
+ const salt = generateSalt();
1043
+ const hash = await hashPassword(password, salt);
1044
+
1045
+ updateEnvFile(
1046
+ { AUTH_PASSWORD_HASH: hash, AUTH_PASSWORD_SALT: salt },
1047
+ ["AUTH_PASSWORD"], // strip any plaintext leftover, just in case
1048
+ );
1049
+
1050
+ console.log("\nPassword set successfully.");
1051
+ console.log(` Stored in: ${ENV_FILE}`);
1052
+
1053
+ const pid = readPid();
1054
+ if (pid) {
1055
+ console.log(" Restart the server to apply: drawlatch restart");
1056
+ }
1057
+ }
1058
+
1059
+ function readTwoLines(rl) {
1060
+ return new Promise((resolveP) => {
1061
+ const lines = [];
1062
+ const onLine = (line) => {
1063
+ lines.push(line);
1064
+ if (lines.length >= 2) {
1065
+ rl.removeListener("line", onLine);
1066
+ resolveP(lines);
1067
+ }
1068
+ };
1069
+ rl.on("line", onLine);
1070
+ rl.once("close", () => {
1071
+ while (lines.length < 2) lines.push("");
1072
+ resolveP(lines);
1073
+ });
1074
+ });
1075
+ }
1076
+
1077
+ function promptPasswordTTY(prompt) {
1078
+ return new Promise((resolveP) => {
1079
+ process.stdout.write(prompt);
1080
+ const stdin = process.stdin;
1081
+ stdin.setRawMode(true);
1082
+ stdin.resume();
1083
+ stdin.setEncoding("utf8");
1084
+ let password = "";
1085
+ const onData = (ch) => {
1086
+ ch = ch.toString();
1087
+ if (ch === "\n" || ch === "\r" || ch === "\u0004") {
1088
+ stdin.setRawMode(false);
1089
+ stdin.pause();
1090
+ stdin.removeListener("data", onData);
1091
+ process.stdout.write("\n");
1092
+ resolveP(password);
1093
+ } else if (ch === "\u0003") {
1094
+ // Ctrl+C
1095
+ stdin.setRawMode(false);
1096
+ process.stdout.write("\n");
1097
+ process.exit(0);
1098
+ } else if (ch === "\u007F" || ch === "\b") {
1099
+ if (password.length > 0) password = password.slice(0, -1);
1100
+ } else {
1101
+ password += ch;
1102
+ }
1103
+ };
1104
+ stdin.on("data", onData);
1105
+ });
1106
+ }
1107
+
1108
+ /** Read ~/.drawlatch/.env and report whether a dashboard password hash is set. */
1109
+ function isPasswordConfigured() {
1110
+ try {
1111
+ const envVars = loadEnvFileVars();
1112
+ return !!envVars.AUTH_PASSWORD_HASH;
1113
+ } catch {
1114
+ return false;
1115
+ }
1116
+ }
1117
+
387
1118
  // ── PID utilities ─────────────────────────────────────────────────
388
1119
 
389
1120
  function isProcessAlive(pid) {
@@ -420,11 +1151,16 @@ function cleanPidFile() {
420
1151
 
421
1152
  // ── Health check utilities ────────────────────────────────────────
422
1153
 
1154
+ /** Resolve the host for client connections — 0.0.0.0 is a bind address, not connectable. */
1155
+ function connectHost(host) {
1156
+ return host === "0.0.0.0" ? "127.0.0.1" : host;
1157
+ }
1158
+
423
1159
  async function healthCheck(host, port) {
424
1160
  try {
425
1161
  const controller = new AbortController();
426
1162
  const timeout = setTimeout(() => controller.abort(), 3000);
427
- const res = await fetch(`http://${host}:${port}/health`, {
1163
+ const res = await fetch(`http://${connectHost(host)}:${port}/health`, {
428
1164
  signal: controller.signal,
429
1165
  });
430
1166
  clearTimeout(timeout);
@@ -438,7 +1174,7 @@ async function healthCheckFull(host, port) {
438
1174
  try {
439
1175
  const controller = new AbortController();
440
1176
  const timeout = setTimeout(() => controller.abort(), 3000);
441
- const res = await fetch(`http://${host}:${port}/health`, {
1177
+ const res = await fetch(`http://${connectHost(host)}:${port}/health`, {
442
1178
  signal: controller.signal,
443
1179
  });
444
1180
  clearTimeout(timeout);
@@ -449,6 +1185,16 @@ async function healthCheckFull(host, port) {
449
1185
  }
450
1186
  }
451
1187
 
1188
+ async function waitForTunnelUrl(host, port, timeoutMs) {
1189
+ const start = Date.now();
1190
+ while (Date.now() - start < timeoutMs) {
1191
+ const data = await healthCheckFull(host, port);
1192
+ if (data?.tunnelUrl) return data.tunnelUrl;
1193
+ await sleep(500);
1194
+ }
1195
+ return null;
1196
+ }
1197
+
452
1198
  async function waitForHealth(host, port, timeoutMs) {
453
1199
  const start = Date.now();
454
1200
  while (Date.now() - start < timeoutMs) {
@@ -476,18 +1222,18 @@ function ensureConfigDir() {
476
1222
  // ── Diagnostic utilities ──────────────────────────────────────────
477
1223
 
478
1224
  async function diagnoseStartFailure() {
479
- if (!existsSync(LOG_FILE)) return;
1225
+ if (!existsSync(LOG_FILE)) {
1226
+ console.log("\n No log file found. The server may not have started at all.");
1227
+ return;
1228
+ }
480
1229
  try {
481
1230
  const content = readFileSync(LOG_FILE, "utf-8");
482
- const lines = content.split("\n").slice(-20);
483
- const eaddrinuse = lines.find((l) => l.includes("EADDRINUSE"));
484
- const eacces = lines.find((l) => l.includes("EACCES"));
485
- if (eaddrinuse) {
486
- console.log("\n Error: Port is already in use.");
487
- console.log(" Another process may be using the same port.");
488
- } else if (eacces) {
489
- console.log("\n Error: Permission denied.");
490
- console.log(" Try using a port >= 1024.");
1231
+ const lines = content.split("\n").filter(Boolean).slice(-15);
1232
+ if (lines.length > 0) {
1233
+ console.log("\n Recent logs:");
1234
+ for (const line of lines) {
1235
+ console.log(` ${line}`);
1236
+ }
491
1237
  }
492
1238
  } catch {
493
1239
  // Best effort
@@ -510,6 +1256,120 @@ function sleep(ms) {
510
1256
  return new Promise((r) => setTimeout(r, ms));
511
1257
  }
512
1258
 
1259
+ // ── Version check utilities ───────────────────────────────────────
1260
+
1261
+ const NPM_PACKAGE_NAME = pkgJson.name;
1262
+
1263
+ function readVersionCache() {
1264
+ try {
1265
+ if (!existsSync(VERSION_CACHE_FILE)) return null;
1266
+ const data = JSON.parse(readFileSync(VERSION_CACHE_FILE, "utf-8"));
1267
+ if (data.checkedAt && Date.now() - data.checkedAt < VERSION_CHECK_TTL_MS) {
1268
+ return data.latestVersion;
1269
+ }
1270
+ return null; // stale
1271
+ } catch {
1272
+ return null;
1273
+ }
1274
+ }
1275
+
1276
+ function writeVersionCache(latestVersion) {
1277
+ try {
1278
+ ensureConfigDir();
1279
+ writeFileSync(
1280
+ VERSION_CACHE_FILE,
1281
+ JSON.stringify({ latestVersion, checkedAt: Date.now() }) + "\n",
1282
+ { mode: 0o600 },
1283
+ );
1284
+ } catch {
1285
+ // Best effort
1286
+ }
1287
+ }
1288
+
1289
+ async function fetchLatestVersion() {
1290
+ try {
1291
+ const controller = new AbortController();
1292
+ const timeout = setTimeout(() => controller.abort(), 5000);
1293
+ const res = await fetch(
1294
+ `https://registry.npmjs.org/${NPM_PACKAGE_NAME}/latest`,
1295
+ { signal: controller.signal },
1296
+ );
1297
+ clearTimeout(timeout);
1298
+ if (!res.ok) return null;
1299
+ const data = await res.json();
1300
+ return data.version || null;
1301
+ } catch {
1302
+ return null;
1303
+ }
1304
+ }
1305
+
1306
+ function compareVersions(a, b) {
1307
+ // Returns > 0 if a > b, < 0 if a < b, 0 if equal
1308
+ // Handles pre-release: 1.0.0 > 1.0.0-alpha.1
1309
+ const parseVer = (v) => {
1310
+ const [core, pre] = v.split("-", 2);
1311
+ const parts = core.split(".").map(Number);
1312
+ return { parts, pre: pre || null };
1313
+ };
1314
+ const va = parseVer(a);
1315
+ const vb = parseVer(b);
1316
+
1317
+ // Compare core version parts
1318
+ const maxLen = Math.max(va.parts.length, vb.parts.length);
1319
+ for (let i = 0; i < maxLen; i++) {
1320
+ const pa = va.parts[i] || 0;
1321
+ const pb = vb.parts[i] || 0;
1322
+ if (pa !== pb) return pa - pb;
1323
+ }
1324
+
1325
+ // Same core: no pre-release > pre-release
1326
+ if (!va.pre && vb.pre) return 1;
1327
+ if (va.pre && !vb.pre) return -1;
1328
+ if (!va.pre && !vb.pre) return 0;
1329
+
1330
+ // Both have pre-release: compare segments
1331
+ const aParts = va.pre.split(".");
1332
+ const bParts = vb.pre.split(".");
1333
+ const preLen = Math.max(aParts.length, bParts.length);
1334
+ for (let i = 0; i < preLen; i++) {
1335
+ const sa = aParts[i];
1336
+ const sb = bParts[i];
1337
+ if (sa === undefined) return -1;
1338
+ if (sb === undefined) return 1;
1339
+ const na = Number(sa);
1340
+ const nb = Number(sb);
1341
+ const aIsNum = !isNaN(na);
1342
+ const bIsNum = !isNaN(nb);
1343
+ if (aIsNum && bIsNum) {
1344
+ if (na !== nb) return na - nb;
1345
+ } else if (aIsNum) {
1346
+ return -1; // numbers sort before strings
1347
+ } else if (bIsNum) {
1348
+ return 1;
1349
+ } else {
1350
+ if (sa < sb) return -1;
1351
+ if (sa > sb) return 1;
1352
+ }
1353
+ }
1354
+ return 0;
1355
+ }
1356
+
1357
+ async function checkForUpdate() {
1358
+ // Try cache first
1359
+ const cached = readVersionCache();
1360
+ if (cached) return cached !== VERSION && compareVersions(cached, VERSION) > 0 ? cached : null;
1361
+
1362
+ // Fetch in background-ish (awaited but with short timeout)
1363
+ const latest = await fetchLatestVersion();
1364
+ if (latest) writeVersionCache(latest);
1365
+ if (latest && latest !== VERSION && compareVersions(latest, VERSION) > 0) return latest;
1366
+ return null;
1367
+ }
1368
+
1369
+ function formatUpdateNotice(latestVersion) {
1370
+ return `\n Update available: ${VERSION} → ${latestVersion}\n Run: npm install -g ${NPM_PACKAGE_NAME}\n`;
1371
+ }
1372
+
513
1373
  // ── Help text ─────────────────────────────────────────────────────
514
1374
 
515
1375
  function printHelp() {
@@ -519,13 +1379,18 @@ drawlatch v${VERSION}
519
1379
  Usage: drawlatch [command] [options]
520
1380
 
521
1381
  Commands:
1382
+ init Set up drawlatch server (keys, config, .env)
522
1383
  start Start the remote server (background by default)
523
1384
  stop Stop the background remote server
524
1385
  restart Restart the background remote server
525
1386
  status Show server status (PID, port, uptime, health, sessions)
526
1387
  logs View and follow remote server logs
527
1388
  config Show effective configuration
1389
+ watch Watch ingestor events in real time
1390
+ doctor Validate setup and diagnose issues
528
1391
  generate-keys Generate Ed25519 + X25519 keypairs
1392
+ sync Exchange keys with a callboard instance
1393
+ set-password Set/change the dashboard password (alias: change-password)
529
1394
 
530
1395
  Options:
531
1396
  -h, --help Show this help message
@@ -534,14 +1399,13 @@ Options:
534
1399
  Running 'drawlatch' with no arguments shows status (if running) or this help.
535
1400
 
536
1401
  Examples:
537
- drawlatch Show status or help
1402
+ drawlatch init Set up the remote server
538
1403
  drawlatch start Start remote server in background
539
1404
  drawlatch start -f Start remote server in foreground
540
- drawlatch start --port 8080 Start on a custom port
1405
+ drawlatch start -f --tunnel Start with a public tunnel for webhooks
1406
+ drawlatch doctor Validate full setup
541
1407
  drawlatch status Check if server is running
542
1408
  drawlatch logs -n 100 View last 100 log lines
543
- drawlatch generate-keys remote Generate remote server keypair
544
- drawlatch generate-keys local mybot Generate local keypair for alias "mybot"
545
1409
  `);
546
1410
  }
547
1411
 
@@ -555,6 +1419,7 @@ Usage: drawlatch start [options]
555
1419
 
556
1420
  Options:
557
1421
  -f, --foreground Run in foreground (default when no command given)
1422
+ -t, --tunnel Start a Cloudflare tunnel for webhook ingestion (requires cloudflared)
558
1423
  --port <number> Override the configured port
559
1424
  --host <address> Override the configured host
560
1425
  -h, --help Show this help message
@@ -617,19 +1482,42 @@ function printLogsHelp() {
617
1482
  console.log(`
618
1483
  drawlatch logs
619
1484
 
620
- View server logs.
1485
+ View server logs (request/response audit lines are hidden by default).
621
1486
 
622
1487
  Usage: drawlatch logs [options]
623
1488
 
624
1489
  Options:
625
1490
  -n, --lines <number> Number of lines to show (default: 50)
626
- --no-follow Print lines and exit (default: follow/tail)
1491
+ --follow Follow/tail the log output (default: print and exit)
1492
+ --requests Include request/response audit lines (noisy with polling)
627
1493
  -h, --help Show this help message
628
1494
 
629
1495
  Log file: ~/.drawlatch/logs/drawlatch.log
630
1496
  `);
631
1497
  }
632
1498
 
1499
+ function printWatchHelp() {
1500
+ console.log(`
1501
+ drawlatch watch
1502
+
1503
+ Watch ingestor events in real time.
1504
+
1505
+ Usage: drawlatch watch [source] [options]
1506
+
1507
+ Arguments:
1508
+ source Filter to a specific connection (e.g., "discord-bot", "github")
1509
+
1510
+ Options:
1511
+ --full Show full event payload (default: truncate to 100 chars)
1512
+ -h, --help Show this help message
1513
+
1514
+ Examples:
1515
+ drawlatch watch Watch all events
1516
+ drawlatch watch github Watch only GitHub events
1517
+ drawlatch watch discord-bot --full Watch Discord events with full payloads
1518
+ `);
1519
+ }
1520
+
633
1521
  function printConfigHelp() {
634
1522
  console.log(`
635
1523
  drawlatch config
@@ -647,6 +1535,86 @@ server configuration including callers and connections.
647
1535
  `);
648
1536
  }
649
1537
 
1538
+ function printInitHelp() {
1539
+ console.log(`
1540
+ drawlatch init
1541
+
1542
+ Set up the drawlatch remote server. Generates server keys, creates
1543
+ config files, and scaffolds a .env template.
1544
+
1545
+ Callers are added separately via 'drawlatch sync' after the server
1546
+ is running.
1547
+
1548
+ Usage: drawlatch init [options]
1549
+
1550
+ Options:
1551
+ -h, --help Show this help message
1552
+
1553
+ All steps are idempotent — safe to re-run without overwriting existing files.
1554
+ `);
1555
+ }
1556
+
1557
+ function printDoctorHelp() {
1558
+ console.log(`
1559
+ drawlatch doctor
1560
+
1561
+ Validate your drawlatch setup and diagnose common issues.
1562
+
1563
+ Usage: drawlatch doctor [options]
1564
+
1565
+ Options:
1566
+ --full Include a live handshake test (requires server running)
1567
+ -h, --help Show this help message
1568
+
1569
+ Checks config files, keys, peer directories, secrets, and server health.
1570
+ Each failure includes the exact command to fix it.
1571
+ `);
1572
+ }
1573
+
1574
+ function printSyncHelp() {
1575
+ console.log(`
1576
+ drawlatch sync
1577
+
1578
+ Exchange keys with a callboard instance using a double-code approval flow.
1579
+
1580
+ Usage: drawlatch sync [options]
1581
+
1582
+ Options:
1583
+ --ttl <seconds> Sync session timeout (default: 300)
1584
+ -h, --help Show this help message
1585
+
1586
+ Flow:
1587
+ 1. Run 'drawlatch sync' — displays an invite code and encryption key
1588
+ 2. Give both values to the callboard operator
1589
+ 3. They enter them into callboard, which generates a confirm code
1590
+ 4. Enter the confirm code when prompted
1591
+ 5. Callboard sends the encrypted sync request
1592
+ 6. Keys are exchanged automatically
1593
+
1594
+ The server must be running (drawlatch start) before using this command.
1595
+ `);
1596
+ }
1597
+
1598
+ function printSetPasswordHelp(cmd = "set-password") {
1599
+ console.log(`
1600
+ drawlatch ${cmd}
1601
+
1602
+ Set or change the password that gates the admin dashboard and /api/admin/*.
1603
+
1604
+ Usage: drawlatch ${cmd} [options]
1605
+
1606
+ Options:
1607
+ -h, --help Show this help message
1608
+
1609
+ Prompts for a new password (hidden in a TTY; reads two lines when piped),
1610
+ hashes it with scrypt, and stores AUTH_PASSWORD_HASH / AUTH_PASSWORD_SALT in
1611
+ ${ENV_FILE}.
1612
+
1613
+ The password must be at least 8 characters. 'change-password' is an alias.
1614
+ Restart the server afterwards (drawlatch restart) to apply the new password.
1615
+ `);
1616
+ }
1617
+
650
1618
  function printGenerateKeysHelp() {
651
1619
  console.log(`
652
1620
  drawlatch generate-keys
@@ -656,10 +1624,11 @@ Generate Ed25519 + X25519 keypairs for authentication and encryption.
656
1624
  Usage: drawlatch generate-keys <subcommand> [options]
657
1625
 
658
1626
  Subcommands:
659
- local [alias] Generate MCP proxy (local) keypair
1627
+ caller [alias] Generate caller keypair
660
1628
  Alias defaults to "default" if omitted.
661
- Keys are stored in keys/local/<alias>/
662
- remote Generate remote server keypair
1629
+ Keys are stored in keys/callers/<alias>/
1630
+ server Generate server keypair
1631
+ Keys are stored in keys/server/
663
1632
  --dir <path> Generate keypair in a custom directory
664
1633
  show <path> Show fingerprint of an existing keypair
665
1634