uv-suite 0.26.0 → 0.26.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/hooks/confirm-prompt.sh +51 -0
- package/hooks/session-label-nag.sh +63 -0
- package/hooks/session-meta.sh +121 -0
- package/hooks/session-start.sh +37 -9
- package/hooks/status-line.sh +50 -25
- package/hooks/watchtower-send.sh +44 -12
- package/install.sh +5 -3
- package/package.json +1 -1
- package/personas/auto.json +77 -19
- package/personas/professional.json +29 -7
- package/personas/spike.json +72 -18
- package/personas/sport.json +72 -18
- package/skills/checkpoint/SKILL.md +17 -5
- package/skills/confirm/SKILL.md +35 -0
- package/skills/restore/SKILL.md +5 -2
- package/skills/session-init/SKILL.md +45 -0
- package/uv.sh +84 -14
- package/watchtower/dashboard.html +137 -22
- package/watchtower/events.json +1 -22
- package/watchtower/server.js +76 -29
|
@@ -279,6 +279,42 @@
|
|
|
279
279
|
.event.user-prompt { background: var(--user-prompt-bg); }
|
|
280
280
|
.event.user-prompt .detail { color: var(--user-prompt-text); font-style: italic; }
|
|
281
281
|
|
|
282
|
+
/* Session priority: low rows are dimmed, high rows get an accent strip */
|
|
283
|
+
.event.priority-low { opacity: 0.45; }
|
|
284
|
+
.event.priority-low:hover { opacity: 0.85; }
|
|
285
|
+
.event.priority-high { border-left: 3px solid var(--accent); padding-left: 25px; }
|
|
286
|
+
|
|
287
|
+
/* Pills shown next to a session label */
|
|
288
|
+
.pill {
|
|
289
|
+
display: inline-block;
|
|
290
|
+
padding: 1px 7px;
|
|
291
|
+
margin-left: 6px;
|
|
292
|
+
font-size: 10.5px;
|
|
293
|
+
font-weight: 600;
|
|
294
|
+
letter-spacing: 0.04em;
|
|
295
|
+
text-transform: uppercase;
|
|
296
|
+
border-radius: 4px;
|
|
297
|
+
vertical-align: 1px;
|
|
298
|
+
}
|
|
299
|
+
.pill.persona-spike { background: rgba(191, 90, 242, 0.18); color: var(--purple); }
|
|
300
|
+
.pill.persona-sport { background: rgba(48, 209, 88, 0.18); color: var(--success); }
|
|
301
|
+
.pill.persona-professional { background: rgba(10, 132, 255, 0.18); color: var(--accent); }
|
|
302
|
+
.pill.persona-auto { background: rgba(255, 159, 10, 0.18); color: var(--warning); }
|
|
303
|
+
.pill.priority-low { background: rgba(154, 154, 163, 0.18); color: var(--text-muted); }
|
|
304
|
+
.pill.priority-med { background: rgba(255, 214, 10, 0.18); color: var(--yellow); }
|
|
305
|
+
.pill.priority-high { background: rgba(255, 69, 58, 0.18); color: var(--danger); }
|
|
306
|
+
.pill.kind-long-running { background: rgba(100, 210, 255, 0.18); color: var(--info); }
|
|
307
|
+
.pill.kind-outcome { background: rgba(255, 105, 97, 0.18); color: var(--peach); }
|
|
308
|
+
|
|
309
|
+
.session-tag.priority-low { opacity: 0.6; }
|
|
310
|
+
.session-tag .meta-line {
|
|
311
|
+
display: block;
|
|
312
|
+
font-size: 11px;
|
|
313
|
+
font-weight: 400;
|
|
314
|
+
color: var(--text-muted);
|
|
315
|
+
margin-top: 2px;
|
|
316
|
+
}
|
|
317
|
+
|
|
282
318
|
.timeline-end { padding: 20px 24px; text-align: center; border-bottom: 1px solid var(--border-subtle); }
|
|
283
319
|
.loader { display: inline-block; width: 48px; height: 4px; position: relative; }
|
|
284
320
|
.loader::before {
|
|
@@ -393,9 +429,21 @@ let lastEventDiv = null;
|
|
|
393
429
|
// Session colors and naming
|
|
394
430
|
const palette = ['#0a84ff','#30d158','#ff9f0a','#bf5af2','#ff375f','#64d2ff','#ffd60a','#ac8ee0','#ff6961','#5e5ce6'];
|
|
395
431
|
let colorIdx = 0;
|
|
432
|
+
|
|
433
|
+
// Resolve the canonical session key for an event: prefer the UV Suite id (one
|
|
434
|
+
// per `uv` launch), fall back to Claude Code's session id, then source_app.
|
|
435
|
+
function eventSid(ev) {
|
|
436
|
+
return ev.uvs_session_id || ev.session_id || ev.source_app || 'unknown';
|
|
437
|
+
}
|
|
438
|
+
|
|
396
439
|
function sessionColor(id) {
|
|
397
440
|
if (!sessions[id]) {
|
|
398
|
-
sessions[id] = {
|
|
441
|
+
sessions[id] = {
|
|
442
|
+
color: palette[colorIdx++ % palette.length],
|
|
443
|
+
count: 0, lastEvent: null,
|
|
444
|
+
name: '', kind: '', purpose: '', priority: '', persona: '',
|
|
445
|
+
app: null, label: null,
|
|
446
|
+
};
|
|
399
447
|
updateSessionBar();
|
|
400
448
|
updateFilterSession();
|
|
401
449
|
}
|
|
@@ -406,33 +454,54 @@ function sessionColor(id) {
|
|
|
406
454
|
|
|
407
455
|
function updateSessionLabel(sid, ev) {
|
|
408
456
|
if (!sessions[sid]) return;
|
|
409
|
-
|
|
410
|
-
|
|
457
|
+
const s = sessions[sid];
|
|
458
|
+
let changed = false;
|
|
459
|
+
|
|
460
|
+
if (!s.app && ev.source_app) { s.app = ev.source_app; changed = true; }
|
|
461
|
+
|
|
462
|
+
// Configured metadata wins over heuristics. Update on every event so a
|
|
463
|
+
// mid-session /session-init relabel is reflected without a refresh.
|
|
464
|
+
for (const [evKey, sKey] of [
|
|
465
|
+
['session_name','name'], ['session_kind','kind'],
|
|
466
|
+
['session_purpose','purpose'], ['session_priority','priority'],
|
|
467
|
+
['persona','persona'],
|
|
468
|
+
]) {
|
|
469
|
+
if (ev[evKey] !== undefined && ev[evKey] !== null && ev[evKey] !== s[sKey]) {
|
|
470
|
+
s[sKey] = ev[evKey];
|
|
471
|
+
changed = true;
|
|
472
|
+
}
|
|
411
473
|
}
|
|
412
|
-
|
|
474
|
+
|
|
475
|
+
// Fall back to first UserPromptSubmit if no configured name yet
|
|
413
476
|
const type = ev.event_type || ev.hook_event_name || '';
|
|
414
|
-
if (!
|
|
477
|
+
if (!s.name && !s.label && type === 'UserPromptSubmit') {
|
|
415
478
|
const prompt = ev.tool_input?.prompt || ev.tool_input?.content || ev.message || '';
|
|
416
479
|
if (prompt.length > 0) {
|
|
417
480
|
let label = prompt.slice(0, 45).replace(/\s+\S*$/, '');
|
|
418
481
|
if (prompt.length > label.length) label += '...';
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
updateFilterSession();
|
|
482
|
+
s.label = label;
|
|
483
|
+
changed = true;
|
|
422
484
|
}
|
|
423
485
|
}
|
|
486
|
+
|
|
487
|
+
if (changed) {
|
|
488
|
+
updateSessionBar();
|
|
489
|
+
updateFilterSession();
|
|
490
|
+
}
|
|
424
491
|
}
|
|
425
492
|
|
|
426
493
|
function sessionDisplayName(id) {
|
|
427
494
|
const s = sessions[id];
|
|
428
495
|
if (!s) return shortId(id);
|
|
429
|
-
if (s.
|
|
430
|
-
|
|
431
|
-
}
|
|
496
|
+
if (s.name) return s.name;
|
|
497
|
+
if (s.label) return s.app ? s.app + ': ' + s.label : s.label;
|
|
432
498
|
if (s.app) return s.app;
|
|
433
499
|
return shortId(id);
|
|
434
500
|
}
|
|
435
501
|
|
|
502
|
+
// Sort order: high → med → unset → low (low last so it groups at the bottom)
|
|
503
|
+
const PRIORITY_ORDER = { high: 0, med: 1, '': 2, low: 3 };
|
|
504
|
+
|
|
436
505
|
function shortId(id) {
|
|
437
506
|
if (!id) return '—';
|
|
438
507
|
return id.length > 10 ? id.slice(0, 8) + '..' : id;
|
|
@@ -526,7 +595,7 @@ function eventDetail(ev) {
|
|
|
526
595
|
}
|
|
527
596
|
|
|
528
597
|
function renderEvent(ev) {
|
|
529
|
-
const sid = ev
|
|
598
|
+
const sid = eventSid(ev);
|
|
530
599
|
const color = sessionColor(sid);
|
|
531
600
|
const type = ev.event_type || ev.hook_event_name || '?';
|
|
532
601
|
const tool = ev.tool_name || '';
|
|
@@ -534,6 +603,7 @@ function renderEvent(ev) {
|
|
|
534
603
|
const fail = isFailure(ev);
|
|
535
604
|
const boundary = isSessionBoundary(ev);
|
|
536
605
|
const prompt = isUserPrompt(ev);
|
|
606
|
+
const priority = sessions[sid]?.priority || '';
|
|
537
607
|
|
|
538
608
|
const div = document.createElement('div');
|
|
539
609
|
let cls = 'event';
|
|
@@ -541,6 +611,8 @@ function renderEvent(ev) {
|
|
|
541
611
|
else if (fail) cls += ' failure';
|
|
542
612
|
else if (prompt) cls += ' user-prompt';
|
|
543
613
|
else if (boundary) cls += ' session-boundary';
|
|
614
|
+
if (priority === 'high') cls += ' priority-high';
|
|
615
|
+
if (priority === 'low') cls += ' priority-low';
|
|
544
616
|
div.className = cls;
|
|
545
617
|
|
|
546
618
|
const humanBadge = human ? '<span class="human-badge">NEEDS HUMAN</span>' : '';
|
|
@@ -553,6 +625,8 @@ function renderEvent(ev) {
|
|
|
553
625
|
<span class="detail">${eventDetail(ev)}</span>
|
|
554
626
|
`;
|
|
555
627
|
|
|
628
|
+
div._ev = ev;
|
|
629
|
+
|
|
556
630
|
// Check filters
|
|
557
631
|
const typeMatch = !selectedType || type === selectedType;
|
|
558
632
|
const sessMatch = !selectedSession || sid === selectedSession;
|
|
@@ -565,7 +639,9 @@ function renderEvent(ev) {
|
|
|
565
639
|
function addEvent(ev) {
|
|
566
640
|
events.push(ev);
|
|
567
641
|
if (emptyState.parentNode) emptyState.remove();
|
|
568
|
-
const sid = ev
|
|
642
|
+
const sid = eventSid(ev);
|
|
643
|
+
// Make sure the session is registered before we try to update its metadata
|
|
644
|
+
sessionColor(sid);
|
|
569
645
|
updateSessionLabel(sid, ev);
|
|
570
646
|
|
|
571
647
|
// Remove "latest" class from previous latest
|
|
@@ -590,7 +666,7 @@ function addEvent(ev) {
|
|
|
590
666
|
|
|
591
667
|
function updateWaitingText(ev) {
|
|
592
668
|
if (ev) {
|
|
593
|
-
const sid = ev
|
|
669
|
+
const sid = eventSid(ev);
|
|
594
670
|
waitingText.textContent = `Last: ${sessionDisplayName(sid)} — ${timeSince(ev._ts)}`;
|
|
595
671
|
}
|
|
596
672
|
}
|
|
@@ -607,20 +683,58 @@ function updateStats() {
|
|
|
607
683
|
const errors = events.filter(e => (e.event_type || e.hook_event_name || '').includes('Failure')).length;
|
|
608
684
|
document.getElementById('errorCount').textContent = errors;
|
|
609
685
|
document.getElementById('errorCount').className = 'n' + (errors > 0 ? ' alert' : '');
|
|
610
|
-
|
|
686
|
+
// Count sessions whose most recent event is waiting on a human — so the
|
|
687
|
+
// number drops back down once the session continues past the prompt.
|
|
688
|
+
const latestBySession = {};
|
|
689
|
+
for (const ev of events) {
|
|
690
|
+
const sid = eventSid(ev);
|
|
691
|
+
latestBySession[sid] = ev;
|
|
692
|
+
}
|
|
693
|
+
const humans = Object.values(latestBySession).filter(needsHuman).length;
|
|
611
694
|
document.getElementById('humanCount').textContent = humans;
|
|
612
695
|
document.getElementById('humanCount').className = 'n' + (humans > 0 ? ' alert' : '');
|
|
613
696
|
}
|
|
614
697
|
|
|
698
|
+
function escapeHtml(s) {
|
|
699
|
+
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function pill(cls, label) {
|
|
703
|
+
return `<span class="pill ${cls}">${escapeHtml(label)}</span>`;
|
|
704
|
+
}
|
|
705
|
+
|
|
615
706
|
function updateSessionBar() {
|
|
616
707
|
sessionBar.innerHTML = '';
|
|
617
|
-
|
|
708
|
+
// Sort: high priority first, then med/unset, then low; within a tier, most
|
|
709
|
+
// recent activity wins so the dashboard surfaces what's happening now.
|
|
710
|
+
const ids = Object.keys(sessions).sort((a, b) => {
|
|
711
|
+
const pa = PRIORITY_ORDER[sessions[a].priority] ?? PRIORITY_ORDER[''];
|
|
712
|
+
const pb = PRIORITY_ORDER[sessions[b].priority] ?? PRIORITY_ORDER[''];
|
|
713
|
+
if (pa !== pb) return pa - pb;
|
|
714
|
+
return (sessions[b].lastEvent || 0) - (sessions[a].lastEvent || 0);
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
for (const id of ids) {
|
|
718
|
+
const s = sessions[id];
|
|
618
719
|
const tag = document.createElement('span');
|
|
619
|
-
|
|
720
|
+
let cls = 'session-tag';
|
|
721
|
+
if (selectedSession === id) cls += ' active';
|
|
722
|
+
if (s.priority === 'low') cls += ' priority-low';
|
|
723
|
+
tag.className = cls;
|
|
620
724
|
tag.style.background = s.color + '22';
|
|
621
725
|
tag.style.color = s.color;
|
|
622
|
-
tag.
|
|
623
|
-
|
|
726
|
+
tag.title = `${id}${s.purpose ? '\n' + s.purpose : ''}`;
|
|
727
|
+
|
|
728
|
+
const pills = [];
|
|
729
|
+
if (s.persona) pills.push(pill('persona-' + s.persona, s.persona));
|
|
730
|
+
if (s.priority) pills.push(pill('priority-' + s.priority, 'P:' + s.priority));
|
|
731
|
+
if (s.kind) pills.push(pill('kind-' + s.kind, s.kind));
|
|
732
|
+
|
|
733
|
+
const namePart = `<strong>${escapeHtml(sessionDisplayName(id))}</strong>` +
|
|
734
|
+
` <span style="opacity:0.7">(${s.count})</span>`;
|
|
735
|
+
const meta = pills.length ? `<span class="meta-line">${pills.join('')}</span>` : '';
|
|
736
|
+
tag.innerHTML = namePart + meta;
|
|
737
|
+
|
|
624
738
|
tag.onclick = () => { selectedSession = selectedSession === id ? '' : id; refilter(); updateSessionBar(); };
|
|
625
739
|
sessionBar.appendChild(tag);
|
|
626
740
|
}
|
|
@@ -650,9 +764,10 @@ function refilter() {
|
|
|
650
764
|
selectedType = filterType.value;
|
|
651
765
|
selectedSession = filterSession.value;
|
|
652
766
|
const rows = timeline.querySelectorAll('.event');
|
|
653
|
-
rows.forEach((row
|
|
654
|
-
const ev =
|
|
655
|
-
|
|
767
|
+
rows.forEach((row) => {
|
|
768
|
+
const ev = row._ev;
|
|
769
|
+
if (!ev) return;
|
|
770
|
+
const sid = eventSid(ev);
|
|
656
771
|
const type = ev.event_type || ev.hook_event_name || '';
|
|
657
772
|
const show = (!selectedType || type === selectedType)
|
|
658
773
|
&& (!selectedSession || sid === selectedSession)
|
package/watchtower/events.json
CHANGED
|
@@ -1,22 +1 @@
|
|
|
1
|
-
[
|
|
2
|
-
{
|
|
3
|
-
"event_type": "PostToolUse",
|
|
4
|
-
"session_id": "test-123",
|
|
5
|
-
"source_app": "uv-suite",
|
|
6
|
-
"tool_name": "Edit",
|
|
7
|
-
"cwd": "/tmp",
|
|
8
|
-
"_ts": 1776756371726,
|
|
9
|
-
"_id": "3f130976-6226-47ea-a477-18a16e194415"
|
|
10
|
-
},
|
|
11
|
-
{
|
|
12
|
-
"event_type": "PostToolUse",
|
|
13
|
-
"session_id": "test-session",
|
|
14
|
-
"source_app": "my-project",
|
|
15
|
-
"tool_name": "Edit",
|
|
16
|
-
"tool_input": {
|
|
17
|
-
"file_path": "src/app.ts"
|
|
18
|
-
},
|
|
19
|
-
"_ts": 1776757586012,
|
|
20
|
-
"_id": "efa9ae75-c0e0-48c2-a078-e7075ccef5a7"
|
|
21
|
-
}
|
|
22
|
-
]
|
|
1
|
+
[]
|
package/watchtower/server.js
CHANGED
|
@@ -4,20 +4,20 @@
|
|
|
4
4
|
// Zero dependencies beyond Node.js
|
|
5
5
|
// Uses Server-Sent Events (SSE) instead of WebSocket — simpler, auto-reconnects
|
|
6
6
|
|
|
7
|
-
const http = require(
|
|
8
|
-
const fs = require(
|
|
9
|
-
const path = require(
|
|
10
|
-
const crypto = require(
|
|
7
|
+
const http = require("http");
|
|
8
|
+
const fs = require("fs");
|
|
9
|
+
const path = require("path");
|
|
10
|
+
const crypto = require("crypto");
|
|
11
11
|
|
|
12
12
|
const PORT = process.env.UVS_WATCHTOWER_PORT || 4200;
|
|
13
|
-
const DATA_FILE = path.join(__dirname,
|
|
13
|
+
const DATA_FILE = path.join(__dirname, "events.json");
|
|
14
14
|
const MAX_EVENTS = 500;
|
|
15
15
|
|
|
16
16
|
// In-memory event store
|
|
17
17
|
let events = [];
|
|
18
18
|
try {
|
|
19
19
|
if (fs.existsSync(DATA_FILE)) {
|
|
20
|
-
events = JSON.parse(fs.readFileSync(DATA_FILE,
|
|
20
|
+
events = JSON.parse(fs.readFileSync(DATA_FILE, "utf-8"));
|
|
21
21
|
}
|
|
22
22
|
} catch (e) {
|
|
23
23
|
events = [];
|
|
@@ -50,20 +50,20 @@ function saveEvents() {
|
|
|
50
50
|
|
|
51
51
|
const server = http.createServer((req, res) => {
|
|
52
52
|
// CORS
|
|
53
|
-
res.setHeader(
|
|
54
|
-
res.setHeader(
|
|
55
|
-
res.setHeader(
|
|
53
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
54
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
55
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
56
56
|
|
|
57
|
-
if (req.method ===
|
|
57
|
+
if (req.method === "OPTIONS") {
|
|
58
58
|
res.writeHead(200);
|
|
59
59
|
return res.end();
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
// POST /events — receive hook events
|
|
63
|
-
if (req.method ===
|
|
64
|
-
let body =
|
|
65
|
-
req.on(
|
|
66
|
-
req.on(
|
|
63
|
+
if (req.method === "POST" && req.url === "/events") {
|
|
64
|
+
let body = "";
|
|
65
|
+
req.on("data", (chunk) => (body += chunk));
|
|
66
|
+
req.on("end", () => {
|
|
67
67
|
try {
|
|
68
68
|
const event = JSON.parse(body);
|
|
69
69
|
event._ts = Date.now();
|
|
@@ -71,7 +71,7 @@ const server = http.createServer((req, res) => {
|
|
|
71
71
|
events.push(event);
|
|
72
72
|
broadcast(event);
|
|
73
73
|
saveEvents();
|
|
74
|
-
res.writeHead(200, {
|
|
74
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
75
75
|
res.end('{"ok":true}');
|
|
76
76
|
} catch (e) {
|
|
77
77
|
res.writeHead(400);
|
|
@@ -82,24 +82,30 @@ const server = http.createServer((req, res) => {
|
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
// GET /stream — SSE endpoint (replaces WebSocket)
|
|
85
|
-
if (req.method ===
|
|
85
|
+
if (req.method === "GET" && req.url === "/stream") {
|
|
86
86
|
res.writeHead(200, {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
87
|
+
"Content-Type": "text/event-stream",
|
|
88
|
+
"Cache-Control": "no-cache",
|
|
89
|
+
Connection: "keep-alive",
|
|
90
90
|
});
|
|
91
91
|
|
|
92
92
|
// Send recent events as init
|
|
93
|
-
res.write(
|
|
93
|
+
res.write(
|
|
94
|
+
`data: ${JSON.stringify({ type: "init", events: events.slice(-100) })}\n\n`,
|
|
95
|
+
);
|
|
94
96
|
|
|
95
97
|
sseClients.add(res);
|
|
96
98
|
|
|
97
99
|
// Keep-alive ping every 15 seconds
|
|
98
100
|
const keepAlive = setInterval(() => {
|
|
99
|
-
try {
|
|
101
|
+
try {
|
|
102
|
+
res.write(": ping\n\n");
|
|
103
|
+
} catch (e) {
|
|
104
|
+
clearInterval(keepAlive);
|
|
105
|
+
}
|
|
100
106
|
}, 15000);
|
|
101
107
|
|
|
102
|
-
req.on(
|
|
108
|
+
req.on("close", () => {
|
|
103
109
|
sseClients.delete(res);
|
|
104
110
|
clearInterval(keepAlive);
|
|
105
111
|
});
|
|
@@ -107,26 +113,67 @@ const server = http.createServer((req, res) => {
|
|
|
107
113
|
}
|
|
108
114
|
|
|
109
115
|
// GET /events — fetch recent events (REST fallback)
|
|
110
|
-
if (req.method ===
|
|
111
|
-
res.writeHead(200, {
|
|
116
|
+
if (req.method === "GET" && req.url.startsWith("/events")) {
|
|
117
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
112
118
|
res.end(JSON.stringify(events.slice(-100)));
|
|
113
119
|
return;
|
|
114
120
|
}
|
|
115
121
|
|
|
116
122
|
// GET / — serve dashboard
|
|
117
|
-
if (req.method ===
|
|
118
|
-
const html = fs.readFileSync(
|
|
119
|
-
|
|
123
|
+
if (req.method === "GET" && (req.url === "/" || req.url === "/index.html")) {
|
|
124
|
+
const html = fs.readFileSync(
|
|
125
|
+
path.join(__dirname, "dashboard.html"),
|
|
126
|
+
"utf-8",
|
|
127
|
+
);
|
|
128
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
120
129
|
res.end(html);
|
|
121
130
|
return;
|
|
122
131
|
}
|
|
123
132
|
|
|
124
133
|
res.writeHead(404);
|
|
125
|
-
res.end(
|
|
134
|
+
res.end("not found");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
server.on("error", (err) => {
|
|
138
|
+
if (err.code !== "EADDRINUSE") {
|
|
139
|
+
console.error("Watchtower server error:", err.message);
|
|
140
|
+
process.exit(1);
|
|
141
|
+
}
|
|
142
|
+
// Port busy — probe to see if it's an existing watchtower or another process
|
|
143
|
+
const req = http.request(
|
|
144
|
+
{ host: "127.0.0.1", port: PORT, path: "/", method: "GET", timeout: 1500 },
|
|
145
|
+
(res) => {
|
|
146
|
+
let body = "";
|
|
147
|
+
res.on("data", (c) => (body += c));
|
|
148
|
+
res.on("end", () => {
|
|
149
|
+
if (/UV Suite Watchtower/.test(body)) {
|
|
150
|
+
console.log(
|
|
151
|
+
`UV Suite Watchtower is already running at http://localhost:${PORT}`,
|
|
152
|
+
);
|
|
153
|
+
process.exit(0);
|
|
154
|
+
} else {
|
|
155
|
+
console.error(`Port ${PORT} is in use by another process.`);
|
|
156
|
+
console.error(`Set UVS_WATCHTOWER_PORT to use a different port.`);
|
|
157
|
+
process.exit(1);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
},
|
|
161
|
+
);
|
|
162
|
+
req.on("error", () => {
|
|
163
|
+
console.error(`Port ${PORT} is in use but not responding.`);
|
|
164
|
+
console.error(`Set UVS_WATCHTOWER_PORT to use a different port.`);
|
|
165
|
+
process.exit(1);
|
|
166
|
+
});
|
|
167
|
+
req.on("timeout", () => {
|
|
168
|
+
req.destroy();
|
|
169
|
+
});
|
|
170
|
+
req.end();
|
|
126
171
|
});
|
|
127
172
|
|
|
128
173
|
server.listen(PORT, () => {
|
|
129
174
|
console.log(`UV Suite Watchtower running at http://localhost:${PORT}`);
|
|
130
175
|
console.log(`${events.length} events loaded from disk`);
|
|
131
|
-
console.log(
|
|
176
|
+
console.log(
|
|
177
|
+
`Waiting for hook events on POST http://localhost:${PORT}/events`,
|
|
178
|
+
);
|
|
132
179
|
});
|