@versdotsh/reef 0.1.4 → 0.1.6

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.
@@ -1,160 +0,0 @@
1
- /**
2
- * UI routes — serves the dashboard, handles magic link auth, proxies API calls.
3
- */
4
-
5
- import { Hono } from "hono";
6
- import { createMagicLink, consumeMagicLink, createSession, validateSession } from "./auth.js";
7
- import { readFileSync } from "node:fs";
8
- import { join } from "node:path";
9
-
10
- const AUTH_TOKEN = process.env.VERS_AUTH_TOKEN || "test-token";
11
-
12
- function getStaticDir(): string {
13
- return join(import.meta.dir, "static");
14
- }
15
-
16
- function getSessionId(c: any): string | undefined {
17
- const cookie = c.req.header("cookie") || "";
18
- const match = cookie.match(/(?:^|;\s*)session=([^;]+)/);
19
- return match?.[1];
20
- }
21
-
22
- function hasBearerAuth(c: any): boolean {
23
- const auth = c.req.header("authorization") || "";
24
- return auth === `Bearer ${AUTH_TOKEN}`;
25
- }
26
-
27
- export function createRoutes(): Hono {
28
- const routes = new Hono();
29
-
30
- // --- Auth ---
31
-
32
- // Generate magic link (requires bearer auth)
33
- routes.post("/auth/magic-link", (c) => {
34
- if (!hasBearerAuth(c)) return c.json({ error: "Unauthorized" }, 401);
35
-
36
- const link = createMagicLink();
37
- const host = c.req.header("host") || "localhost:3000";
38
- const proto = c.req.header("x-forwarded-proto") || "https";
39
- const url = `${proto}://${host}/ui/login?token=${link.token}`;
40
- return c.json({ url, expiresAt: link.expiresAt });
41
- });
42
-
43
- // Login page / magic link consumer
44
- routes.get("/ui/login", (c) => {
45
- const token = c.req.query("token");
46
-
47
- if (token) {
48
- const valid = consumeMagicLink(token);
49
- if (valid) {
50
- const session = createSession();
51
- return c.html(
52
- `<html><head><meta http-equiv="refresh" content="0;url=/ui/"></head></html>`,
53
- 200,
54
- { "Set-Cookie": `session=${session.id}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400` },
55
- );
56
- }
57
- return c.html(`
58
- <html><body style="background:#0a0a0a;color:#f55;font-family:monospace;padding:2em">
59
- <h2>Invalid or expired link</h2>
60
- <p>Request a new magic link from the API.</p>
61
- </body></html>
62
- `, 401);
63
- }
64
-
65
- return c.html(`
66
- <html><body style="background:#0a0a0a;color:#888;font-family:monospace;padding:2em">
67
- <h2>Fleet Services Dashboard</h2>
68
- <p>Access requires a magic link. Generate one via:</p>
69
- <pre style="color:#4f9">POST /auth/magic-link</pre>
70
- </body></html>
71
- `);
72
- });
73
-
74
- // --- Session-protected UI routes ---
75
-
76
- routes.use("/ui/*", async (c, next) => {
77
- const path = new URL(c.req.url).pathname;
78
- if (path === "/ui/login" || path.startsWith("/ui/static/")) return next();
79
-
80
- // In dev mode (no auth token set), skip session check
81
- if (!process.env.VERS_AUTH_TOKEN) return next();
82
-
83
- const sessionId = getSessionId(c);
84
- if (!validateSession(sessionId)) return c.redirect("/ui/login");
85
- return next();
86
- });
87
-
88
- // Dashboard
89
- routes.get("/ui/", (c) => {
90
- try {
91
- const html = readFileSync(join(getStaticDir(), "index.html"), "utf-8");
92
- return c.html(html);
93
- } catch {
94
- return c.text("Dashboard files not found", 500);
95
- }
96
- });
97
-
98
- // Static files
99
- routes.get("/ui/static/:file", (c) => {
100
- const file = c.req.param("file");
101
- if (file.includes("..") || file.includes("/")) return c.text("Not found", 404);
102
-
103
- try {
104
- const content = readFileSync(join(getStaticDir(), file), "utf-8");
105
- const ext = file.split(".").pop();
106
- const contentType =
107
- ext === "css" ? "text/css" :
108
- ext === "js" ? "application/javascript" :
109
- "text/plain";
110
- return c.body(content, 200, { "Content-Type": contentType });
111
- } catch {
112
- return c.text("Not found", 404);
113
- }
114
- });
115
-
116
- // --- API proxy (injects bearer token so browser never needs it) ---
117
-
118
- routes.all("/ui/api/*", async (c) => {
119
- const url = new URL(c.req.url);
120
- const apiPath = url.pathname.replace(/^\/ui\/api/, "");
121
- const queryString = url.search;
122
-
123
- const port = process.env.PORT || "3000";
124
- const internalUrl = `http://127.0.0.1:${port}${apiPath}${queryString}`;
125
-
126
- const headers: Record<string, string> = {
127
- Authorization: `Bearer ${AUTH_TOKEN}`,
128
- };
129
- const contentType = c.req.header("content-type");
130
- if (contentType) headers["Content-Type"] = contentType;
131
-
132
- const method = c.req.method;
133
- const body = method !== "GET" && method !== "HEAD" ? await c.req.text() : undefined;
134
-
135
- try {
136
- const resp = await fetch(internalUrl, { method, headers, body });
137
-
138
- // SSE passthrough
139
- if (resp.headers.get("content-type")?.includes("text/event-stream")) {
140
- return new Response(resp.body, {
141
- status: resp.status,
142
- headers: {
143
- "Content-Type": "text/event-stream",
144
- "Cache-Control": "no-cache",
145
- Connection: "keep-alive",
146
- },
147
- });
148
- }
149
-
150
- const text = await resp.text();
151
- return c.body(text, resp.status as any, {
152
- "Content-Type": resp.headers.get("content-type") || "application/json",
153
- });
154
- } catch (e) {
155
- return c.json({ error: "Proxy error", details: String(e) }, 502);
156
- }
157
- });
158
-
159
- return routes;
160
- }
@@ -1,369 +0,0 @@
1
- // =============================================================================
2
- // reef UI — dynamic panel discovery + built-in chat
3
- // =============================================================================
4
-
5
- const API = PANEL_API; // set in index.html
6
-
7
- function esc(s) {
8
- const d = document.createElement('div');
9
- d.textContent = s || '';
10
- return d.innerHTML;
11
- }
12
-
13
- function timeAgo(iso) {
14
- const ms = Date.now() - new Date(iso).getTime();
15
- if (ms < 60000) return `${Math.floor(ms / 1000)}s ago`;
16
- if (ms < 3600000) return `${Math.floor(ms / 60000)}m ago`;
17
- if (ms < 86400000) return `${Math.floor(ms / 3600000)}h ago`;
18
- return `${Math.floor(ms / 86400000)}d ago`;
19
- }
20
-
21
- // =============================================================================
22
- // Panel discovery
23
- // =============================================================================
24
-
25
- const tabsEl = document.getElementById('tabs');
26
- const panelsEl = document.getElementById('panels');
27
- const statusEl = document.getElementById('status');
28
- let activeTab = null;
29
- const loadedPanels = new Map(); // name → container element
30
-
31
- async function discoverPanels() {
32
- try {
33
- // Get loaded services
34
- const res = await fetch(`${API}/services`);
35
- if (!res.ok) throw new Error(`${res.status}`);
36
- const data = await res.json();
37
- const services = data.modules || data.services || (Array.isArray(data) ? data : []);
38
-
39
- // Try to fetch _panel for each service (skip ui itself)
40
- const panelResults = await Promise.allSettled(
41
- services
42
- .filter(s => s.name !== 'ui')
43
- .map(async (s) => {
44
- const r = await fetch(`${API}/${s.name}/_panel`);
45
- if (!r.ok) return null;
46
- const ct = r.headers.get('content-type') || '';
47
- if (!ct.includes('html')) return null;
48
- return { name: s.name, html: await r.text() };
49
- })
50
- );
51
-
52
- const panels = panelResults
53
- .filter(r => r.status === 'fulfilled' && r.value)
54
- .map(r => r.value);
55
-
56
- // Build tabs: discovered panels first, then chat
57
- tabsEl.innerHTML = '';
58
-
59
- for (const panel of panels) {
60
- addTab(panel.name, panel.name);
61
- }
62
- addTab('chat', 'Chat');
63
-
64
- // Inject panel HTML
65
- for (const panel of panels) {
66
- if (!loadedPanels.has(panel.name)) {
67
- const container = document.createElement('div');
68
- container.className = 'panel-view';
69
- container.id = `view-${panel.name}`;
70
- container.dataset.api = API;
71
- panelsEl.appendChild(container);
72
- injectPanel(container, panel.html);
73
- loadedPanels.set(panel.name, container);
74
- }
75
- }
76
-
77
- // Remove panels for services that were unloaded
78
- const activeNames = new Set(panels.map(p => p.name));
79
- for (const [name, el] of loadedPanels) {
80
- if (!activeNames.has(name)) {
81
- el.remove();
82
- loadedPanels.delete(name);
83
- // Remove tab
84
- tabsEl.querySelector(`[data-view="${name}"]`)?.remove();
85
- }
86
- }
87
-
88
- // Activate first tab if none active
89
- if (!activeTab || !document.getElementById(`view-${activeTab}`)) {
90
- const first = panels[0]?.name || 'chat';
91
- switchTab(first);
92
- }
93
-
94
- setStatus('ok', `${panels.length} panels`);
95
- } catch (e) {
96
- setStatus('err', e.message);
97
- }
98
- }
99
-
100
- function addTab(name, label) {
101
- const btn = document.createElement('button');
102
- btn.className = 'tab' + (activeTab === name ? ' active' : '');
103
- btn.dataset.view = name;
104
- btn.textContent = label;
105
- btn.addEventListener('click', () => switchTab(name));
106
- tabsEl.appendChild(btn);
107
- }
108
-
109
- function switchTab(name) {
110
- activeTab = name;
111
-
112
- // Update tab highlight
113
- tabsEl.querySelectorAll('.tab').forEach(t => {
114
- t.classList.toggle('active', t.dataset.view === name);
115
- });
116
-
117
- // Show/hide panels
118
- document.querySelectorAll('.panel-view').forEach(v => {
119
- v.classList.toggle('active', v.id === `view-${name}`);
120
- });
121
-
122
- // Lazy-start chat session
123
- if (name === 'chat') {
124
- if (!chatSessionId) chatCreateSession();
125
- document.getElementById('chat-input')?.focus();
126
- }
127
- }
128
-
129
- function injectPanel(container, html) {
130
- // Inject HTML without scripts
131
- const temp = document.createElement('div');
132
- temp.innerHTML = html;
133
-
134
- // Extract scripts
135
- const scripts = [];
136
- temp.querySelectorAll('script').forEach(s => {
137
- scripts.push(s.textContent);
138
- s.remove();
139
- });
140
-
141
- // Inject HTML
142
- container.innerHTML = temp.innerHTML;
143
-
144
- // Execute scripts in order
145
- for (const code of scripts) {
146
- const s = document.createElement('script');
147
- s.textContent = code;
148
- container.appendChild(s);
149
- }
150
- }
151
-
152
- function setStatus(state, text) {
153
- statusEl.className = 'status ' + state;
154
- statusEl.querySelector('.label').textContent = text;
155
- }
156
-
157
- // =============================================================================
158
- // Chat
159
- // =============================================================================
160
-
161
- let chatSessionId = null;
162
- let chatStreaming = false;
163
- let chatCurrentEl = null;
164
- let chatCurrentText = '';
165
-
166
- function chatEl(id) { return document.getElementById(id); }
167
-
168
- async function chatCreateSession() {
169
- try {
170
- const res = await fetch(`${API}/agent/sessions`, { method: 'POST' });
171
- const data = await res.json();
172
- if (data.error) throw new Error(data.error);
173
- chatSessionId = data.id;
174
- chatConnectSSE();
175
- const empty = chatEl('chat-messages').querySelector('.chat-empty');
176
- if (empty) empty.remove();
177
- } catch (e) {
178
- chatAddMsg('system', `Failed to start session: ${e.message}`);
179
- }
180
- }
181
-
182
- function chatConnectSSE() {
183
- if (!chatSessionId) return;
184
- fetch(`${API}/agent/sessions/${chatSessionId}/events`)
185
- .then(res => {
186
- if (!res.ok) throw new Error(`SSE ${res.status}`);
187
- chatReadSSE(res.body.getReader());
188
- })
189
- .catch(e => {
190
- chatAddMsg('system', `Disconnected: ${e.message}`);
191
- setTimeout(() => { if (chatSessionId) chatConnectSSE(); }, 3000);
192
- });
193
- }
194
-
195
- async function chatReadSSE(reader) {
196
- const dec = new TextDecoder();
197
- let buf = '';
198
- try {
199
- while (true) {
200
- const { done, value } = await reader.read();
201
- if (done) break;
202
- buf += dec.decode(value, { stream: true });
203
- const lines = buf.split('\n');
204
- buf = lines.pop() || '';
205
- for (const line of lines) {
206
- if (line.startsWith('data: ')) {
207
- try { chatHandleEvent(JSON.parse(line.slice(6))); } catch {}
208
- }
209
- }
210
- }
211
- } catch {}
212
- }
213
-
214
- function chatHandleEvent(e) {
215
- switch (e.type) {
216
- case 'agent_start':
217
- chatStreaming = true;
218
- chatEl('chat-send').textContent = 'Stop';
219
- break;
220
- case 'agent_end':
221
- chatStreaming = false;
222
- chatFinish();
223
- chatEl('chat-send').textContent = 'Send';
224
- break;
225
- case 'message_update': {
226
- const d = e.assistantMessageEvent;
227
- if (d?.type === 'text_delta') {
228
- chatEnsure();
229
- chatCurrentText += d.delta;
230
- chatRender();
231
- }
232
- break;
233
- }
234
- case 'tool_execution_start':
235
- chatEnsure();
236
- chatAddTool(e.toolCallId, e.toolName, e.args);
237
- break;
238
- case 'tool_execution_update':
239
- chatUpdateTool(e.toolCallId, e.partialResult);
240
- break;
241
- case 'tool_execution_end':
242
- chatUpdateTool(e.toolCallId, e.result, e.isError);
243
- break;
244
- }
245
- }
246
-
247
- function chatEnsure() {
248
- if (chatCurrentEl) return;
249
- chatCurrentEl = document.createElement('div');
250
- chatCurrentEl.className = 'chat-msg';
251
- chatCurrentEl.innerHTML = '<div class="chat-msg-role assistant">assistant</div><div class="chat-msg-content"></div>';
252
- chatEl('chat-messages').appendChild(chatCurrentEl);
253
- chatCurrentText = '';
254
- }
255
-
256
- function chatRender() {
257
- if (!chatCurrentEl) return;
258
- let t = chatCurrentEl.querySelector('.chat-text');
259
- if (!t) {
260
- t = document.createElement('span');
261
- t.className = 'chat-text';
262
- const c = chatCurrentEl.querySelector('.chat-msg-content');
263
- c.insertBefore(t, c.firstChild);
264
- }
265
- t.innerHTML = chatMd(chatCurrentText) + '<span class="chat-cursor"></span>';
266
- chatScroll();
267
- }
268
-
269
- function chatFinish() {
270
- if (!chatCurrentEl) return;
271
- const t = chatCurrentEl.querySelector('.chat-text');
272
- if (t) t.innerHTML = chatMd(chatCurrentText);
273
- chatCurrentEl.querySelector('.chat-cursor')?.remove();
274
- chatCurrentEl = null;
275
- chatCurrentText = '';
276
- }
277
-
278
- function chatAddTool(id, name, args) {
279
- const preview = args
280
- ? Object.values(args).map(v => { const s = typeof v === 'string' ? v : JSON.stringify(v); return s.length > 50 ? s.slice(0, 50) + '…' : s; }).join(', ')
281
- : '';
282
- const el = document.createElement('div');
283
- el.className = 'chat-tool';
284
- el.dataset.toolCallId = id;
285
- el.innerHTML = `
286
- <div class="chat-tool-header" onclick="this.querySelector('.chat-tool-arrow').classList.toggle('open');this.nextElementSibling.classList.toggle('open')">
287
- <span class="chat-tool-arrow">▶</span>
288
- <span>${esc(name)}(${esc(preview)})</span>
289
- </div>
290
- <div class="chat-tool-body"></div>`;
291
- chatCurrentEl.querySelector('.chat-msg-content').appendChild(el);
292
- chatScroll();
293
- }
294
-
295
- function chatUpdateTool(id, result, isError) {
296
- const el = chatCurrentEl?.querySelector(`[data-tool-call-id="${id}"]`);
297
- if (!el) return;
298
- const body = el.querySelector('.chat-tool-body');
299
- const text = result?.content?.filter(c => c.type === 'text').map(c => c.text).join('') || '';
300
- body.textContent = text.slice(-2000);
301
- if (isError) body.classList.add('chat-tool-error');
302
- }
303
-
304
- function chatAddMsg(role, text) {
305
- const el = document.createElement('div');
306
- el.className = 'chat-msg';
307
- el.innerHTML = `<div class="chat-msg-role ${role}">${role === 'user' ? 'you' : role}</div><div class="chat-msg-content">${esc(text)}</div>`;
308
- chatEl('chat-messages').appendChild(el);
309
- chatScroll();
310
- }
311
-
312
- function chatMd(text) {
313
- text = text.replace(/```(\w*)\n([\s\S]*?)```/g, (_, l, c) => `<pre><code>${esc(c.trimEnd())}</code></pre>`);
314
- text = text.replace(/`([^`]+)`/g, '<code>$1</code>');
315
- text = text.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
316
- return text.split(/(<pre>[\s\S]*?<\/pre>)/g).map(p => p.startsWith('<pre>') ? p : p.replace(/\n/g, '<br>')).join('');
317
- }
318
-
319
- function chatScroll() {
320
- requestAnimationFrame(() => {
321
- const el = chatEl('chat-messages');
322
- el.scrollTop = el.scrollHeight;
323
- });
324
- }
325
-
326
- async function chatSend() {
327
- if (!chatSessionId) return;
328
- if (chatStreaming) {
329
- fetch(`${API}/agent/sessions/${chatSessionId}/abort`, { method: 'POST' }).catch(() => {});
330
- return;
331
- }
332
- const input = chatEl('chat-input');
333
- const text = input.value.trim();
334
- if (!text) return;
335
- input.value = '';
336
- input.style.height = '36px';
337
- chatAddMsg('user', text);
338
- chatFinish();
339
- try {
340
- const res = await fetch(`${API}/agent/sessions/${chatSessionId}/message`, {
341
- method: 'POST',
342
- headers: { 'Content-Type': 'application/json' },
343
- body: JSON.stringify({ message: text }),
344
- });
345
- const data = await res.json();
346
- if (data.error) chatAddMsg('system', data.error);
347
- } catch (e) {
348
- chatAddMsg('system', e.message);
349
- }
350
- }
351
-
352
- // Chat input handlers
353
- chatEl('chat-send').addEventListener('click', chatSend);
354
- chatEl('chat-input').addEventListener('keydown', (e) => {
355
- if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); chatSend(); }
356
- });
357
- chatEl('chat-input').addEventListener('input', () => {
358
- const el = chatEl('chat-input');
359
- el.style.height = '36px';
360
- el.style.height = Math.min(el.scrollHeight, 200) + 'px';
361
- });
362
-
363
- // =============================================================================
364
- // Init
365
- // =============================================================================
366
-
367
- discoverPanels();
368
- // Re-discover periodically (picks up loaded/unloaded services)
369
- setInterval(discoverPanels, 30000);
@@ -1,42 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="UTF-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>reef</title>
7
- <link rel="stylesheet" href="/ui/static/style.css">
8
- </head>
9
- <body>
10
- <div id="app">
11
- <header>
12
- <h1>▸ reef</h1>
13
- <nav class="tabs" id="tabs"></nav>
14
- <div class="status" id="status">
15
- <span class="dot"></span>
16
- <span class="label">loading</span>
17
- </div>
18
- </header>
19
-
20
- <div id="panels"></div>
21
-
22
- <!-- Chat is always built-in, not a panel -->
23
- <div class="panel-view" id="view-chat">
24
- <div class="chat-container">
25
- <div class="chat-messages" id="chat-messages">
26
- <div class="chat-empty">
27
- <div class="chat-empty-title">reef agent</div>
28
- <div class="chat-empty-sub">Send a message to start a session</div>
29
- </div>
30
- </div>
31
- <div class="chat-input-area">
32
- <textarea id="chat-input" placeholder="Send a message… (Enter to send, Shift+Enter for newline)" rows="1"></textarea>
33
- <button id="chat-send">Send</button>
34
- </div>
35
- </div>
36
- </div>
37
- </div>
38
-
39
- <script>const PANEL_API = '/ui/api';</script>
40
- <script src="/ui/static/app.js"></script>
41
- </body>
42
- </html>