@wolpertingerlabs/drawlatch 1.0.0-alpha.12.0 → 1.0.0-alpha.15.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/drawlatch.js CHANGED
@@ -81,6 +81,7 @@ try {
81
81
  follow: { type: "boolean", default: false },
82
82
  path: { type: "boolean", default: false },
83
83
  full: { type: "boolean", default: false },
84
+ requests: { type: "boolean", default: false },
84
85
  ttl: { type: "string", default: "300" },
85
86
  },
86
87
  strict: false,
@@ -185,6 +186,13 @@ switch (subcommand) {
185
186
  await cmdSync();
186
187
  }
187
188
  break;
189
+ case "watch":
190
+ if (values.help) {
191
+ printWatchHelp();
192
+ } else {
193
+ await cmdWatch();
194
+ }
195
+ break;
188
196
  case "help":
189
197
  printHelp();
190
198
  {
@@ -477,39 +485,154 @@ async function cmdLogs() {
477
485
 
478
486
  const lines = parseInt(values.lines, 10) || 50;
479
487
  const follow = values.follow;
488
+ const showRequests = values.requests;
480
489
 
481
490
  const tailArgs = follow
482
491
  ? ["-n", String(lines), "-f", LOG_FILE]
483
492
  : ["-n", String(lines), LOG_FILE];
484
493
 
485
- const tail = spawn("tail", tailArgs, { stdio: "inherit" });
494
+ if (showRequests) {
495
+ // Show everything — pipe directly to stdout
496
+ const tail = spawn("tail", tailArgs, { stdio: "inherit" });
486
497
 
487
- tail.on("error", () => {
488
- // Fallback: read last N lines with Node.js if tail is not available
489
- try {
490
- const content = readFileSync(LOG_FILE, "utf-8");
491
- const allLines = content.split("\n");
492
- const lastLines = allLines.slice(-lines).join("\n");
493
- console.log(lastLines);
494
- if (follow) {
495
- console.log(
496
- "\n(Live following not available \u2014 'tail' command not found)",
497
- );
498
- }
499
- } catch (err) {
500
- console.error(`Error reading log file: ${err.message}`);
501
- process.exit(1);
498
+ tail.on("error", () => logsFallback(lines, follow, null));
499
+
500
+ process.on("SIGINT", () => {
501
+ tail.kill();
502
+ process.exit(0);
503
+ });
504
+
505
+ await new Promise((res) => tail.on("close", res));
506
+ } else {
507
+ // Filter out [audit] lines (request/response noise from poll_events etc.)
508
+ const tail = spawn("tail", tailArgs, { stdio: ["ignore", "pipe", "inherit"] });
509
+ const grepProc = spawn("grep", ["-v", "^\\[audit\\]"], {
510
+ stdio: [tail.stdout, "inherit", "inherit"],
511
+ });
512
+
513
+ tail.on("error", () => logsFallback(lines, follow, "[audit]"));
514
+
515
+ process.on("SIGINT", () => {
516
+ tail.kill();
517
+ grepProc.kill();
518
+ process.exit(0);
519
+ });
520
+
521
+ await new Promise((res) => grepProc.on("close", res));
522
+ }
523
+ }
524
+
525
+ function logsFallback(lines, follow, filterPrefix) {
526
+ try {
527
+ const content = readFileSync(LOG_FILE, "utf-8");
528
+ let allLines = content.split("\n");
529
+ if (filterPrefix) {
530
+ allLines = allLines.filter((l) => !l.startsWith(filterPrefix));
502
531
  }
503
- });
532
+ const lastLines = allLines.slice(-lines).join("\n");
533
+ console.log(lastLines);
534
+ if (follow) {
535
+ console.log(
536
+ "\n(Live following not available \u2014 'tail' command not found)",
537
+ );
538
+ }
539
+ } catch (err) {
540
+ console.error(`Error reading log file: ${err.message}`);
541
+ process.exit(1);
542
+ }
543
+ }
504
544
 
505
- // Forward SIGINT to cleanly exit
545
+ async function cmdWatch() {
546
+ const config = loadRemoteConfig();
547
+ const port = config.port;
548
+ const host = config.host;
549
+
550
+ // Verify the server is running
551
+ const healthy = await healthCheck(host, port);
552
+ if (!healthy) {
553
+ console.error("Remote server is not running. Start it first:");
554
+ console.error(" drawlatch start");
555
+ process.exit(1);
556
+ }
557
+
558
+ const showFull = values.full;
559
+ const sourceFilter = positionals[0] || null;
560
+
561
+ console.log("Watching for events" + (sourceFilter ? ` from "${sourceFilter}"` : "") + "...");
562
+ console.log("Press Ctrl+C to stop.\n");
563
+
564
+ const controller = new AbortController();
506
565
  process.on("SIGINT", () => {
507
- tail.kill();
566
+ controller.abort();
508
567
  process.exit(0);
509
568
  });
510
569
 
511
- // Wait for tail to exit (when using --no-follow)
512
- await new Promise((res) => tail.on("close", res));
570
+ try {
571
+ const res = await fetch(
572
+ `http://${connectHost(host)}:${port}/events/stream`,
573
+ { signal: controller.signal },
574
+ );
575
+
576
+ if (!res.ok) {
577
+ console.error(`Failed to connect to event stream: HTTP ${res.status}`);
578
+ process.exit(1);
579
+ }
580
+
581
+ const decoder = new TextDecoder();
582
+ let buffer = "";
583
+
584
+ for await (const chunk of res.body) {
585
+ buffer += decoder.decode(chunk, { stream: true });
586
+
587
+ // Parse SSE lines
588
+ let newlineIdx;
589
+ while ((newlineIdx = buffer.indexOf("\n\n")) !== -1) {
590
+ const message = buffer.slice(0, newlineIdx);
591
+ buffer = buffer.slice(newlineIdx + 2);
592
+
593
+ for (const line of message.split("\n")) {
594
+ if (!line.startsWith("data: ")) continue;
595
+
596
+ try {
597
+ const event = JSON.parse(line.slice(6));
598
+
599
+ // Apply source filter if set
600
+ if (sourceFilter && event.source !== sourceFilter) continue;
601
+
602
+ const time = new Date(event.receivedAt).toLocaleTimeString();
603
+ const source = event.source;
604
+ const instance = event.instanceId ? `:${event.instanceId}` : "";
605
+ const caller = event.callerAlias || "?";
606
+ const eventType = event.eventType;
607
+
608
+ const dataStr = JSON.stringify(event.data);
609
+
610
+ if (showFull) {
611
+ console.log(
612
+ `\x1b[2m${time}\x1b[0m \x1b[36m${source}${instance}\x1b[0m \x1b[33m${eventType}\x1b[0m \x1b[2m(${caller})\x1b[0m`
613
+ );
614
+ console.log(dataStr);
615
+ console.log("");
616
+ } else {
617
+ const preview =
618
+ dataStr.length > 100
619
+ ? dataStr.slice(0, 100) + "…"
620
+ : dataStr;
621
+ console.log(
622
+ `\x1b[2m${time}\x1b[0m \x1b[36m${source}${instance}\x1b[0m \x1b[33m${eventType}\x1b[0m \x1b[2m(${caller})\x1b[0m ${preview}`
623
+ );
624
+ }
625
+ } catch {
626
+ // Skip malformed SSE data
627
+ }
628
+ }
629
+ }
630
+ }
631
+ } catch (err) {
632
+ if (err.name === "AbortError") return;
633
+ console.error(`Event stream error: ${err.message}`);
634
+ process.exit(1);
635
+ }
513
636
  }
514
637
 
515
638
  function cmdConfig() {
@@ -1126,6 +1249,7 @@ Commands:
1126
1249
  status Show server status (PID, port, uptime, health, sessions)
1127
1250
  logs View and follow remote server logs
1128
1251
  config Show effective configuration
1252
+ watch Watch ingestor events in real time
1129
1253
  doctor Validate setup and diagnose issues
1130
1254
  generate-keys Generate Ed25519 + X25519 keypairs
1131
1255
  sync Exchange keys with a callboard instance
@@ -1220,19 +1344,42 @@ function printLogsHelp() {
1220
1344
  console.log(`
1221
1345
  drawlatch logs
1222
1346
 
1223
- View server logs.
1347
+ View server logs (request/response audit lines are hidden by default).
1224
1348
 
1225
1349
  Usage: drawlatch logs [options]
1226
1350
 
1227
1351
  Options:
1228
1352
  -n, --lines <number> Number of lines to show (default: 50)
1229
1353
  --follow Follow/tail the log output (default: print and exit)
1354
+ --requests Include request/response audit lines (noisy with polling)
1230
1355
  -h, --help Show this help message
1231
1356
 
1232
1357
  Log file: ~/.drawlatch/logs/drawlatch.log
1233
1358
  `);
1234
1359
  }
1235
1360
 
1361
+ function printWatchHelp() {
1362
+ console.log(`
1363
+ drawlatch watch
1364
+
1365
+ Watch ingestor events in real time.
1366
+
1367
+ Usage: drawlatch watch [source] [options]
1368
+
1369
+ Arguments:
1370
+ source Filter to a specific connection (e.g., "discord-bot", "github")
1371
+
1372
+ Options:
1373
+ --full Show full event payload (default: truncate to 100 chars)
1374
+ -h, --help Show this help message
1375
+
1376
+ Examples:
1377
+ drawlatch watch Watch all events
1378
+ drawlatch watch github Watch only GitHub events
1379
+ drawlatch watch discord-bot --full Watch Discord events with full payloads
1380
+ `);
1381
+ }
1382
+
1236
1383
  function printConfigHelp() {
1237
1384
  console.log(`
1238
1385
  drawlatch config
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "GitHub API",
3
- "stability": "beta",
3
+ "stability": "stable",
4
4
  "category": "developer-tools",
5
5
  "description": "GitHub REST API — repositories, issues, pull requests, users, organizations, and more. Auth is handled automatically via the GITHUB_TOKEN environment variable. Includes a webhook ingestor for real-time events (push, pull_request, issues, etc.) — set GITHUB_WEBHOOK_SECRET and use poll_events to retrieve them.",
6
6
  "docsUrl": "https://docs.github.com/en/rest",
@@ -49,7 +49,7 @@
49
49
  "active": true,
50
50
  "events": ["push", "pull_request", "issues", "issue_comment", "create", "delete", "release", "workflow_run", "check_run"],
51
51
  "config": {
52
- "url": "${GITHUB_WEBHOOK_URL}/webhooks/github",
52
+ "url": "${GITHUB_WEBHOOK_URL}",
53
53
  "content_type": "json",
54
54
  "secret": "${GITHUB_WEBHOOK_SECRET}",
55
55
  "insecure_ssl": "0"
@@ -82,12 +82,21 @@
82
82
  {
83
83
  "key": "repoFilter",
84
84
  "label": "Repository Filter",
85
- "description": "Only capture webhook events from these repositories (owner/repo format). Leave empty for all.",
85
+ "description": "Register a repo-level webhook and only capture events from this repository (owner/repo format). Mutually exclusive with Organization for registration.",
86
86
  "type": "text[]",
87
87
  "instanceKey": true,
88
88
  "placeholder": "e.g., octocat/Hello-World",
89
89
  "group": "Filtering"
90
90
  },
91
+ {
92
+ "key": "orgFilter",
93
+ "label": "Organization",
94
+ "description": "Register an org-level webhook that receives events from all repos in the organization. Mutually exclusive with Repository Filter for registration.",
95
+ "type": "text",
96
+ "instanceKey": true,
97
+ "placeholder": "e.g., my-org",
98
+ "group": "Filtering"
99
+ },
91
100
  {
92
101
  "key": "eventFilter",
93
102
  "label": "Event Types",
@@ -45,6 +45,8 @@ export declare class IngestorManager {
45
45
  private ingestors;
46
46
  /** Trigger rule engines per caller. Created during startAll() for callers with triggerRules. */
47
47
  private triggerEngines;
48
+ /** Global event listeners (e.g. SSE streams). Called for every event from every ingestor. */
49
+ private eventListeners;
48
50
  /**
49
51
  * Optional config loader for hot-reload support. When provided, `startOne()`
50
52
  * uses it to get fresh config from disk instead of the constructor snapshot.
@@ -90,6 +92,15 @@ export declare class IngestorManager {
90
92
  * Get status of all ingestors for a caller.
91
93
  */
92
94
  getStatuses(callerAlias: string): IngestorStatus[];
95
+ /**
96
+ * Subscribe to all events from all ingestors (current and future).
97
+ * Used by the SSE /events/stream endpoint to fan out events to CLI watchers.
98
+ */
99
+ onEvent(listener: (event: IngestedEvent) => void): void;
100
+ /** Unsubscribe a global event listener. */
101
+ offEvent(listener: (event: IngestedEvent) => void): void;
102
+ /** Forward an ingestor event to all global listeners. */
103
+ private notifyEventListeners;
93
104
  /**
94
105
  * Find all webhook ingestor instances that match a given webhook path.
95
106
  * Returns all matching instances across all callers (for fan-out dispatch).
@@ -51,6 +51,8 @@ export class IngestorManager {
51
51
  ingestors = new Map();
52
52
  /** Trigger rule engines per caller. Created during startAll() for callers with triggerRules. */
53
53
  triggerEngines = new Map();
54
+ /** Global event listeners (e.g. SSE streams). Called for every event from every ingestor. */
55
+ eventListeners = new Set();
54
56
  /**
55
57
  * Optional config loader for hot-reload support. When provided, `startOne()`
56
58
  * uses it to get fresh config from disk instead of the constructor snapshot.
@@ -142,6 +144,11 @@ export class IngestorManager {
142
144
  const { caller } = parseKey(key);
143
145
  ingestor.callerAlias = caller;
144
146
  this.ingestors.set(key, ingestor);
147
+ // Forward events to global listeners (SSE streams, etc.)
148
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any -- BaseIngestor extends EventEmitter; .on() is inherited
149
+ ingestor.on('event', (event) => {
150
+ this.notifyEventListeners(event);
151
+ });
145
152
  log.info(`Starting ${effectiveConfig.type} ingestor for ${key}`);
146
153
  try {
147
154
  await ingestor.start();
@@ -250,6 +257,28 @@ export class IngestorManager {
250
257
  }
251
258
  return statuses;
252
259
  }
260
+ /**
261
+ * Subscribe to all events from all ingestors (current and future).
262
+ * Used by the SSE /events/stream endpoint to fan out events to CLI watchers.
263
+ */
264
+ onEvent(listener) {
265
+ this.eventListeners.add(listener);
266
+ }
267
+ /** Unsubscribe a global event listener. */
268
+ offEvent(listener) {
269
+ this.eventListeners.delete(listener);
270
+ }
271
+ /** Forward an ingestor event to all global listeners. */
272
+ notifyEventListeners(event) {
273
+ for (const listener of this.eventListeners) {
274
+ try {
275
+ listener(event);
276
+ }
277
+ catch {
278
+ // Don't let a broken listener crash ingestor event processing
279
+ }
280
+ }
281
+ }
253
282
  /**
254
283
  * Find all webhook ingestor instances that match a given webhook path.
255
284
  * Returns all matching instances across all callers (for fan-out dispatch).
@@ -362,6 +391,11 @@ export class IngestorManager {
362
391
  }
363
392
  ingestor.callerAlias = callerAlias;
364
393
  this.ingestors.set(key, ingestor);
394
+ // Forward events to global listeners (SSE streams, etc.)
395
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any -- BaseIngestor extends EventEmitter; .on() is inherited
396
+ ingestor.on('event', (event) => {
397
+ this.notifyEventListeners(event);
398
+ });
365
399
  log.info(`Starting ${effectiveConfig.type} ingestor for ${key}`);
366
400
  try {
367
401
  await ingestor.start();
@@ -545,6 +579,13 @@ export class IngestorManager {
545
579
  if (typeof paramValue === 'string') {
546
580
  secrets[paramKey] = paramValue;
547
581
  }
582
+ else if (Array.isArray(paramValue) &&
583
+ paramValue.length > 0 &&
584
+ typeof paramValue[0] === 'string') {
585
+ // For text[] instanceKey fields, inject the first element into secrets
586
+ // for lifecycle URL ${VAR} resolution (e.g., repoFilter → "owner/repo")
587
+ secrets[paramKey] = paramValue[0];
588
+ }
548
589
  }
549
590
  }
550
591
  }
@@ -19,12 +19,23 @@ export declare class GitHubWebhookIngestor extends WebhookIngestor {
19
19
  * Set via `_repoFilter` on the webhook config (injected by IngestorManager).
20
20
  */
21
21
  private readonly repoFilter;
22
+ /**
23
+ * Organization filter for org-level webhook registration.
24
+ * When set, lifecycle URLs target the org API instead of the repo API.
25
+ * Set via `_orgFilter` on the webhook config (injected by IngestorManager).
26
+ */
27
+ private readonly orgFilter;
22
28
  constructor(connectionAlias: string, secrets: Record<string, string>, webhookConfig: WebhookIngestorConfig, bufferSize?: number, instanceId?: string);
23
29
  /**
24
- * Return the repository name for multi-instance webhook lifecycle management.
25
- * Enables the lifecycle manager to match and clean up stale webhooks per-repo.
30
+ * Return the model ID for multi-instance webhook lifecycle management.
31
+ * For org-level: the org name. For repo-level: the single repo name.
26
32
  */
27
33
  protected getModelId(): string | undefined;
34
+ /**
35
+ * Clone webhookConfig with lifecycle URLs rewritten for org-level endpoints.
36
+ * Replaces `repos/${repoFilter}` with `orgs/${orgFilter}` in all lifecycle URLs.
37
+ */
38
+ private static withOrgLifecycle;
28
39
  /**
29
40
  * Filter webhooks by repository for multi-instance support.
30
41
  * When repoFilter is set, only events from those repos are accepted.
@@ -23,19 +23,58 @@ export class GitHubWebhookIngestor extends WebhookIngestor {
23
23
  * Set via `_repoFilter` on the webhook config (injected by IngestorManager).
24
24
  */
25
25
  repoFilter;
26
+ /**
27
+ * Organization filter for org-level webhook registration.
28
+ * When set, lifecycle URLs target the org API instead of the repo API.
29
+ * Set via `_orgFilter` on the webhook config (injected by IngestorManager).
30
+ */
31
+ orgFilter;
26
32
  constructor(connectionAlias, secrets, webhookConfig, bufferSize, instanceId) {
27
- super(connectionAlias, secrets, webhookConfig, bufferSize, instanceId);
28
- // Repo filter for multi-instance discrimination
29
33
  // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any -- injected by IngestorManager for multi-instance support
30
- this.repoFilter = webhookConfig._repoFilter ?? [];
34
+ const orgFilter = webhookConfig._orgFilter;
35
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
36
+ const repoFilterArr = webhookConfig._repoFilter;
37
+ // When orgFilter is set, swap lifecycle URLs to use org endpoints
38
+ if (orgFilter && webhookConfig.lifecycle) {
39
+ webhookConfig = GitHubWebhookIngestor.withOrgLifecycle(webhookConfig);
40
+ }
41
+ else if (!repoFilterArr?.length && !orgFilter && webhookConfig.lifecycle) {
42
+ // Neither filter set — lifecycle registration can't determine the target
43
+ log.warn(`${connectionAlias}: No repoFilter or orgFilter set — webhook auto-registration disabled. ` +
44
+ 'Set repoFilter (owner/repo) or orgFilter (org name) to enable.');
45
+ webhookConfig = { ...webhookConfig, lifecycle: undefined };
46
+ }
47
+ super(connectionAlias, secrets, webhookConfig, bufferSize, instanceId);
48
+ this.repoFilter = repoFilterArr ?? [];
49
+ this.orgFilter = orgFilter;
31
50
  }
32
51
  /**
33
- * Return the repository name for multi-instance webhook lifecycle management.
34
- * Enables the lifecycle manager to match and clean up stale webhooks per-repo.
52
+ * Return the model ID for multi-instance webhook lifecycle management.
53
+ * For org-level: the org name. For repo-level: the single repo name.
35
54
  */
36
55
  getModelId() {
56
+ if (this.orgFilter)
57
+ return this.orgFilter;
37
58
  return this.repoFilter.length === 1 ? this.repoFilter[0] : undefined;
38
59
  }
60
+ /**
61
+ * Clone webhookConfig with lifecycle URLs rewritten for org-level endpoints.
62
+ * Replaces `repos/${repoFilter}` with `orgs/${orgFilter}` in all lifecycle URLs.
63
+ */
64
+ static withOrgLifecycle(config) {
65
+ if (!config.lifecycle)
66
+ return config;
67
+ const lc = config.lifecycle;
68
+ const swap = (url) => url.replace('repos/${repoFilter}', 'orgs/${orgFilter}');
69
+ return {
70
+ ...config,
71
+ lifecycle: {
72
+ list: lc.list ? { ...lc.list, url: swap(lc.list.url) } : undefined,
73
+ register: lc.register ? { ...lc.register, url: swap(lc.register.url) } : undefined,
74
+ unregister: lc.unregister ? { ...lc.unregister, url: swap(lc.unregister.url) } : undefined,
75
+ },
76
+ };
77
+ }
39
78
  /**
40
79
  * Filter webhooks by repository for multi-instance support.
41
80
  * When repoFilter is set, only events from those repos are accepted.
@@ -1427,6 +1427,23 @@ export function createApp(options = {}) {
1427
1427
  uptime: process.uptime(),
1428
1428
  });
1429
1429
  });
1430
+ // ── Event stream (loopback-only, for `drawlatch watch`) ─────────────
1431
+ app.get('/events/stream', requireLoopback, (req, res) => {
1432
+ res.writeHead(200, {
1433
+ 'Content-Type': 'text/event-stream',
1434
+ 'Cache-Control': 'no-cache',
1435
+ Connection: 'keep-alive',
1436
+ });
1437
+ res.flushHeaders();
1438
+ const mgr = app.locals.ingestorManager;
1439
+ const listener = (event) => {
1440
+ res.write(`data: ${JSON.stringify(event)}\n\n`);
1441
+ };
1442
+ mgr.onEvent(listener);
1443
+ req.on('close', () => {
1444
+ mgr.offEvent(listener);
1445
+ });
1446
+ });
1430
1447
  // ── Webhook receiver ─────────────────────────────────────────────────
1431
1448
  // Trello (and potentially other services) send a HEAD request to the
1432
1449
  // callback URL to verify it is reachable before activating the webhook.
@@ -1476,6 +1493,8 @@ export function createApp(options = {}) {
1476
1493
  });
1477
1494
  return app;
1478
1495
  }
1496
+ // waitForTunnelReady is now exported from tunnel.ts — imported dynamically
1497
+ // alongside startTunnel in the tunnel startup block below.
1479
1498
  // ── Start ──────────────────────────────────────────────────────────────────
1480
1499
  export function main() {
1481
1500
  console.log('[remote] Starting drawlatch server...');
@@ -1520,7 +1539,7 @@ export function main() {
1520
1539
  // process.env.DRAWLATCH_TUNNEL_URL is available during secret resolution.
1521
1540
  if (useTunnel) {
1522
1541
  try {
1523
- const { startTunnel } = await import('./tunnel.js');
1542
+ const { startTunnel, waitForTunnelReady } = await import('./tunnel.js');
1524
1543
  const tunnel = await startTunnel({ port, host });
1525
1544
  stopTunnel = tunnel.stop;
1526
1545
  process.env.DRAWLATCH_TUNNEL_URL = tunnel.url;
@@ -1537,16 +1556,29 @@ export function main() {
1537
1556
  const match = /^\$\{(\w+)\}$/.exec(callbackTpl);
1538
1557
  if (match) {
1539
1558
  const envVar = match[1];
1559
+ const fullUrl = `${tunnel.url}/webhooks/${webhookPath}`;
1560
+ // Set bare env var
1540
1561
  if (!process.env[envVar]) {
1541
- const fullUrl = `${tunnel.url}/webhooks/${webhookPath}`;
1542
1562
  process.env[envVar] = fullUrl;
1543
1563
  console.log(`[remote] Auto-set ${envVar}=${fullUrl}`);
1544
1564
  }
1565
+ // Also set prefixed env var so caller-scoped secret resolution
1566
+ // (which checks PREFIX_VAR, not bare VAR) can find it.
1567
+ const prefix = callerAlias.toUpperCase().replace(/-/g, '_');
1568
+ const prefixedEnvVar = `${prefix}_${envVar}`;
1569
+ if (!process.env[prefixedEnvVar]) {
1570
+ process.env[prefixedEnvVar] = fullUrl;
1571
+ console.log(`[remote] Auto-set ${prefixedEnvVar}=${fullUrl}`);
1572
+ }
1545
1573
  }
1546
1574
  }
1547
1575
  }
1548
1576
  console.log(`[remote] Tunnel active: ${tunnel.url}`);
1549
1577
  console.log(`[remote] Webhook URL: ${tunnel.url}/webhooks/<path>`);
1578
+ // Wait for the tunnel to be fully connected before starting ingestors.
1579
+ // cloudflared reports the URL before the QUIC connection is established;
1580
+ // services like Trello validate the callback URL during registration.
1581
+ await waitForTunnelReady(tunnel.url, 10_000);
1550
1582
  }
1551
1583
  catch (err) {
1552
1584
  console.error('[remote] Failed to start tunnel:', err);
@@ -37,4 +37,17 @@ export declare function isCloudflaredAvailable(): Promise<boolean>;
37
37
  * emit a URL within the configured timeout.
38
38
  */
39
39
  export declare function startTunnel(options: TunnelOptions): Promise<TunnelResult>;
40
+ /**
41
+ * Wait for a tunnel to be fully connected by probing a URL through it.
42
+ *
43
+ * cloudflared emits the tunnel URL before the QUIC connection is fully
44
+ * established. Services like Trello validate the callback URL at registration
45
+ * time, so callers should wait until the tunnel is actually reachable before
46
+ * starting webhook ingestors.
47
+ *
48
+ * @param tunnelUrl The public tunnel URL to probe (e.g. https://abc.trycloudflare.com).
49
+ * @param timeoutMs How long to wait before giving up (default: 10 000 ms).
50
+ * @param probePath Path to probe on the tunnel (default: "/health").
51
+ */
52
+ export declare function waitForTunnelReady(tunnelUrl: string, timeoutMs?: number, probePath?: string): Promise<void>;
40
53
  //# sourceMappingURL=tunnel.d.ts.map
@@ -113,4 +113,37 @@ export async function startTunnel(options) {
113
113
  }
114
114
  });
115
115
  }
116
+ // ── Tunnel readiness probe ──────────────────────────────────────────────
117
+ /**
118
+ * Wait for a tunnel to be fully connected by probing a URL through it.
119
+ *
120
+ * cloudflared emits the tunnel URL before the QUIC connection is fully
121
+ * established. Services like Trello validate the callback URL at registration
122
+ * time, so callers should wait until the tunnel is actually reachable before
123
+ * starting webhook ingestors.
124
+ *
125
+ * @param tunnelUrl The public tunnel URL to probe (e.g. https://abc.trycloudflare.com).
126
+ * @param timeoutMs How long to wait before giving up (default: 10 000 ms).
127
+ * @param probePath Path to probe on the tunnel (default: "/health").
128
+ */
129
+ export async function waitForTunnelReady(tunnelUrl, timeoutMs = 10_000, probePath = '/health') {
130
+ const start = Date.now();
131
+ while (Date.now() - start < timeoutMs) {
132
+ try {
133
+ const resp = await fetch(`${tunnelUrl}${probePath}`, {
134
+ method: 'GET',
135
+ signal: AbortSignal.timeout(2000),
136
+ });
137
+ if (resp.ok) {
138
+ log.info('Tunnel connectivity verified');
139
+ return;
140
+ }
141
+ }
142
+ catch {
143
+ // Tunnel not ready yet — retry
144
+ }
145
+ await new Promise((r) => setTimeout(r, 500));
146
+ }
147
+ log.warn('Tunnel readiness probe timed out — webhook registration may fail');
148
+ }
116
149
  //# sourceMappingURL=tunnel.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wolpertingerlabs/drawlatch",
3
- "version": "1.0.0-alpha.12.0",
3
+ "version": "1.0.0-alpha.15.2",
4
4
  "description": "Encrypted MCP proxy with mutual authentication. Local MCP server forwards requests through an encrypted channel to a remote secrets-holding server.",
5
5
  "type": "module",
6
6
  "main": "./dist/mcp/server.js",
@@ -1,78 +0,0 @@
1
- {
2
- "name": "GitHub Events API (Poll)",
3
- "stability": "beta",
4
- "category": "developer-tools",
5
- "description": "GitHub Events REST API — polls repository events for push, pull_request, issues, and more. A webhook-free alternative to the GitHub webhook connection. Auth is handled automatically via the GITHUB_TOKEN environment variable. Uses ETag caching to minimize rate limit consumption. Use poll_events to retrieve buffered events.",
6
- "docsUrl": "https://docs.github.com/en/rest/activity/events",
7
- "headers": {
8
- "Authorization": "Bearer ${GITHUB_TOKEN}",
9
- "Accept": "application/vnd.github+json",
10
- "X-GitHub-Api-Version": "2022-11-28",
11
- "User-Agent": "drawlatch"
12
- },
13
- "secrets": {
14
- "GITHUB_TOKEN": "${GITHUB_TOKEN}"
15
- },
16
- "allowedEndpoints": ["https://api.github.com/**"],
17
- "ingestor": {
18
- "type": "poll",
19
- "poll": {
20
- "url": "https://api.github.com/repos/${GITHUB_POLL_REPO}/events",
21
- "intervalMs": 60000,
22
- "method": "GET",
23
- "deduplicateBy": "id",
24
- "eventType": "github_event",
25
- "etag": true
26
- }
27
- },
28
- "testIngestor": {
29
- "description": "Executes a single poll of the repository events endpoint to verify access",
30
- "strategy": "poll_once",
31
- "request": {
32
- "method": "GET",
33
- "url": "https://api.github.com/repos/${GITHUB_POLL_REPO}/events?per_page=1",
34
- "expectedStatus": [200]
35
- }
36
- },
37
- "listenerConfig": {
38
- "name": "GitHub Events Poll Listener",
39
- "description": "Polls the GitHub Events API for repository activity. A webhook-free alternative — ideal for environments without a public URL.",
40
- "supportsMultiInstance": true,
41
- "fields": [
42
- {
43
- "key": "GITHUB_POLL_REPO",
44
- "label": "Repository",
45
- "description": "Repository to poll for events (owner/repo format).",
46
- "type": "text",
47
- "instanceKey": true,
48
- "overrideKey": "GITHUB_POLL_REPO",
49
- "placeholder": "e.g., octocat/Hello-World",
50
- "group": "Connection"
51
- },
52
- {
53
- "key": "intervalMs",
54
- "label": "Poll Interval (ms)",
55
- "description": "How often to check for new events, in milliseconds. GitHub caches events for ~60s, so polling faster is wasteful.",
56
- "type": "number",
57
- "default": 60000,
58
- "min": 30000,
59
- "max": 3600000,
60
- "group": "Connection"
61
- },
62
- {
63
- "key": "bufferSize",
64
- "label": "Buffer Size",
65
- "description": "Maximum number of events to keep in memory.",
66
- "type": "number",
67
- "default": 200,
68
- "min": 10,
69
- "max": 1000,
70
- "group": "Advanced"
71
- }
72
- ]
73
- },
74
- "testConnection": {
75
- "url": "https://api.github.com/user",
76
- "description": "Fetches the authenticated user profile"
77
- }
78
- }