@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 +169 -22
- package/dist/connections/developer-tools/github.json +12 -3
- package/dist/remote/ingestors/manager.d.ts +11 -0
- package/dist/remote/ingestors/manager.js +41 -0
- package/dist/remote/ingestors/webhook/github-webhook-ingestor.d.ts +13 -2
- package/dist/remote/ingestors/webhook/github-webhook-ingestor.js +44 -5
- package/dist/remote/server.js +34 -2
- package/dist/remote/tunnel.d.ts +13 -0
- package/dist/remote/tunnel.js +33 -0
- package/package.json +1 -1
- package/dist/connections/developer-tools/github-events-poll.json +0 -78
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
|
-
|
|
494
|
+
if (showRequests) {
|
|
495
|
+
// Show everything — pipe directly to stdout
|
|
496
|
+
const tail = spawn("tail", tailArgs, { stdio: "inherit" });
|
|
486
497
|
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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
|
-
|
|
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
|
-
|
|
566
|
+
controller.abort();
|
|
508
567
|
process.exit(0);
|
|
509
568
|
});
|
|
510
569
|
|
|
511
|
-
|
|
512
|
-
|
|
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": "
|
|
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}
|
|
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": "
|
|
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
|
|
25
|
-
*
|
|
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
|
-
|
|
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
|
|
34
|
-
*
|
|
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.
|
package/dist/remote/server.js
CHANGED
|
@@ -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);
|
package/dist/remote/tunnel.d.ts
CHANGED
|
@@ -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
|
package/dist/remote/tunnel.js
CHANGED
|
@@ -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.
|
|
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
|
-
}
|