@unbound-app/debugger 0.1.4 → 1.0.1

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/TODO.md ADDED
@@ -0,0 +1,2 @@
1
+ - Add this as an installable global binary.
2
+ - .help, .clearhistory, etc.
package/package.json CHANGED
@@ -1,34 +1,21 @@
1
1
  {
2
2
  "name": "@unbound-app/debugger",
3
- "version": "0.1.4",
4
- "description": "A REPL debugger for @unbound-app/client runtime instances.",
5
- "author": {
6
- "name": "Mario P.",
7
- "email": "contact@marioparaschiv.com",
8
- "url": "https://github.com/marioparaschiv"
9
- },
10
- "repository": {
11
- "type": "git",
12
- "url": "git+https://github.com/marioparaschiv/unbound.git",
13
- "directory": "packages/debugger"
14
- },
3
+ "description": "A REPL debugger for @unbound-app instances.",
4
+ "version": "1.0.1",
5
+ "module": "src/index.ts",
6
+ "type": "module",
15
7
  "bin": {
16
- "ub-debugger": "dist/index.mjs"
8
+ "ub-debugger": "src/index.ts"
17
9
  },
18
- "files": [
19
- "dist"
20
- ],
21
- "type": "module",
22
- "module": "./dist/index.mjs",
23
- "scripts": {
24
- "build": "tsdown"
10
+ "devDependencies": {
11
+ "@types/bun": "latest",
12
+ "@types/terminal-kit": "^2.5.7"
13
+ },
14
+ "peerDependencies": {
15
+ "typescript": "^5"
25
16
  },
26
17
  "dependencies": {
27
- "@unbound-app/debugger-protocol": "0.1.3",
28
18
  "cleye": "^1.3.4",
29
19
  "terminal-kit": "^3.1.2"
30
- },
31
- "peerDependencies": {
32
- "typescript": "^5.9.3"
33
20
  }
34
- }
21
+ }
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env bun
2
+ import { loadHistory } from './lib/history';
3
+ import { setupWebSocket } from './lib/ws';
4
+
5
+
6
+ await loadHistory();
7
+ await setupWebSocket();
package/src/lib/cli.ts ADDED
@@ -0,0 +1,15 @@
1
+ import { cli } from 'cleye';
2
+
3
+
4
+ const argv = cli({
5
+ name: 'ub-debugger',
6
+ flags: {
7
+ port: {
8
+ type: Number,
9
+ description: 'The port number to host the debugger on.',
10
+ default: 9090
11
+ }
12
+ }
13
+ });
14
+
15
+ export default argv;
@@ -0,0 +1,29 @@
1
+ import { terminal } from 'terminal-kit';
2
+ import { join } from 'node:path';
3
+
4
+
5
+ type History = string[];
6
+
7
+ const HISTORY_PATH = join(__dirname, '..', '..', '.history.json');
8
+ const HISTORY_FILE = Bun.file(HISTORY_PATH);
9
+
10
+ export let history: History = [];
11
+
12
+ export async function loadHistory() {
13
+ try {
14
+ if (await HISTORY_FILE.exists()) {
15
+ history = await HISTORY_FILE.json();
16
+ }
17
+ } catch (error) {
18
+ terminal.red.bold(`Failed to load history.\n`);
19
+ }
20
+ }
21
+
22
+ export function addHistoryItem(item: string) {
23
+ history.push(item);
24
+ persistHistory();
25
+ }
26
+
27
+ export function persistHistory() {
28
+ HISTORY_FILE.write(JSON.stringify(history, null, 2));
29
+ }
@@ -0,0 +1,47 @@
1
+ import { terminal } from 'terminal-kit';
2
+
3
+ import { addHistoryItem, history } from './history';
4
+ import { send } from './ws';
5
+
6
+
7
+ export const state: {
8
+ repl: ReturnType<typeof terminal.inputField> | null;
9
+ } = {
10
+ repl: null
11
+ };
12
+
13
+ export function setupReplListeners() {
14
+ terminal.on('key', (name: string) => {
15
+ if (name !== 'CTRL_C') return;
16
+ process.exit(0);
17
+ });
18
+ }
19
+
20
+ export async function takeReplInput() {
21
+ abortCurrentReplInput();
22
+
23
+ terminal.bold.cyan('» ');
24
+
25
+ state.repl = terminal.inputField({
26
+ minLength: 1,
27
+ history
28
+ }, (error, input) => {
29
+ if (error) return console.error(error);
30
+ if (!input) return;
31
+
32
+ abortCurrentReplInput(true);
33
+ terminal.white.bold(`» ${input}\n`);
34
+ addHistoryItem(input);
35
+ send(input);
36
+
37
+ takeReplInput();
38
+ });
39
+ }
40
+
41
+ export function abortCurrentReplInput(shouldDeletePrompt: boolean = false) {
42
+ if (state.repl) {
43
+ state.repl.abort();
44
+ state.repl = null;
45
+ if (shouldDeletePrompt) terminal.deleteLine(-1);
46
+ }
47
+ }
package/src/lib/ws.ts ADDED
@@ -0,0 +1,97 @@
1
+ import { terminal } from 'terminal-kit';
2
+ import os from 'node:os';
3
+
4
+ import { abortCurrentReplInput, setupReplListeners, takeReplInput } from './repl';
5
+ import argv from './cli';
6
+
7
+
8
+ interface SocketMessage {
9
+ level: 1 | 2 | 3;
10
+ message: string;
11
+ }
12
+
13
+ export const state: {
14
+ isInitialized: boolean,
15
+ socket: Bun.ServerWebSocket | null;
16
+ } = {
17
+ isInitialized: false,
18
+ socket: null
19
+ };
20
+
21
+ export const MESSAGE_LEVELS = {
22
+ 1: terminal.gray,
23
+ 2: terminal.yellow,
24
+ 3: terminal.red
25
+ };
26
+
27
+ export function setupWebSocket() {
28
+ if (state.isInitialized) return;
29
+
30
+ Bun.serve({
31
+ port: argv.flags.port,
32
+ fetch(request, server) {
33
+ if (state.socket) {
34
+ return new Response(
35
+ JSON.stringify({ error: 'Debugger is busy.' }),
36
+ { status: 503 }
37
+ );
38
+ }
39
+
40
+ server.upgrade(request);
41
+ },
42
+ websocket: {
43
+ open(ws) {
44
+ state.socket = ws;
45
+ terminal.bold.green('Client connected.\n');
46
+
47
+ setupReplListeners();
48
+ takeReplInput();
49
+ },
50
+ message(_, message: string) {
51
+ try {
52
+ const payload: SocketMessage = JSON.parse(message);
53
+ abortCurrentReplInput(true);
54
+
55
+ MESSAGE_LEVELS[payload.level](`« ${payload.message}\n`);
56
+
57
+ takeReplInput();
58
+ } catch (error) {
59
+ terminal.red(`Parsing failed on message from client.\n`);
60
+ }
61
+ },
62
+ close() {
63
+ state.socket = null;
64
+ abortCurrentReplInput(true);
65
+ terminal.red('Client disconnected.\n');
66
+ }
67
+ }
68
+ });
69
+
70
+ const localAddress = getLocalAddress();
71
+
72
+ terminal.gray(`Unbound's Debugger is now active. Connect using ${localAddress}:${argv.flags.port}\n`);
73
+ }
74
+
75
+ export function getLocalAddress() {
76
+ const interfaces = os.networkInterfaces();
77
+
78
+ for (const ifaces of Object.values(interfaces)) {
79
+ if (!ifaces) continue;
80
+
81
+ for (const iface of ifaces) {
82
+ // Skip over internal (i.e., 127.0.0.1) and non-IPv4 addresses
83
+ if (iface.family === 'IPv4' && !iface.internal) {
84
+ return iface.address;
85
+ }
86
+ }
87
+ }
88
+ };
89
+
90
+
91
+ export function send(...payload: Parameters<Bun.ServerWebSocket['send']>) {
92
+ if (!state.socket) {
93
+ return terminal.bold.red('You cannot send messages without a connected client.\n');
94
+ }
95
+
96
+ state.socket.send(...payload);
97
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "compilerOptions": {
3
+ // Environment setup & latest features
4
+ "lib": ["ESNext"],
5
+ "target": "ESNext",
6
+ "module": "Preserve",
7
+ "moduleDetection": "force",
8
+ "jsx": "react-jsx",
9
+ "allowJs": true,
10
+
11
+ // Bundler mode
12
+ "moduleResolution": "bundler",
13
+ "allowImportingTsExtensions": true,
14
+ "verbatimModuleSyntax": true,
15
+ "noEmit": true,
16
+
17
+ // Best practices
18
+ "strict": true,
19
+ "skipLibCheck": true,
20
+ "noFallthroughCasesInSwitch": true,
21
+ "noUncheckedIndexedAccess": true,
22
+ "noImplicitOverride": true,
23
+
24
+ // Some stricter flags (disabled by default)
25
+ "noUnusedLocals": false,
26
+ "noUnusedParameters": false,
27
+ "noPropertyAccessFromIndexSignature": false
28
+ }
29
+ }
package/dist/index.d.mts DELETED
@@ -1 +0,0 @@
1
- export {}
package/dist/index.mjs DELETED
@@ -1,550 +0,0 @@
1
- #!/usr/bin/env bun
2
- import { stripEscapeSequences, terminal } from "terminal-kit";
3
- import { join } from "node:path";
4
- import { parseMessage } from "@unbound-app/debugger-protocol";
5
- import os from "node:os";
6
- import { cli } from "cleye";
7
- //#region src/lib/history.ts
8
- const HISTORY_PATH = join(__dirname, "..", "..", ".history.json");
9
- const HISTORY_FILE = Bun.file(HISTORY_PATH);
10
- let history = [];
11
- async function loadHistory() {
12
- try {
13
- if (await HISTORY_FILE.exists()) history = await HISTORY_FILE.json();
14
- } catch (error) {
15
- terminal.red.bold(`Failed to load history.\n`);
16
- }
17
- }
18
- function addHistoryItem(item) {
19
- history.push(item);
20
- persistHistory();
21
- }
22
- function persistHistory() {
23
- HISTORY_FILE.write(JSON.stringify(history, null, 2));
24
- }
25
- //#endregion
26
- //#region src/lib/session.ts
27
- const state$2 = {
28
- device: null,
29
- controllers: /* @__PURE__ */ new Set()
30
- };
31
- const pending = /* @__PURE__ */ new Map();
32
- const logListeners = /* @__PURE__ */ new Set();
33
- const deviceListeners = /* @__PURE__ */ new Set();
34
- const evalListeners = /* @__PURE__ */ new Set();
35
- const resultListeners = /* @__PURE__ */ new Set();
36
- const controllerListeners = /* @__PURE__ */ new Set();
37
- /** How long to wait for a device to answer an eval before failing it. */
38
- const EVAL_TIMEOUT_MS = 1e4;
39
- /**
40
- * @description Registers the connected device, rejecting a second one so eval routing stays
41
- * unambiguous.
42
- * @param socket The device's socket.
43
- * @returns Whether the device was accepted (false if one is already connected).
44
- */
45
- function attachDevice(socket) {
46
- if (state$2.device) return false;
47
- state$2.device = socket;
48
- broadcastDeviceStatus(true);
49
- return true;
50
- }
51
- /**
52
- * @description Clears the device on disconnect, notifies controllers, and fails every in-flight eval
53
- * so no caller waits forever. Twin of {@link attachDevice}.
54
- * @param socket The socket that closed; ignored if it isn't the current device.
55
- */
56
- function detachDevice(socket) {
57
- if (state$2.device !== socket) return;
58
- state$2.device = null;
59
- for (const [id, entry] of pending) {
60
- clearTimeout(entry.timer);
61
- entry.deliver({
62
- type: "eval-result",
63
- id,
64
- ok: false,
65
- error: "Device disconnected before the evaluation completed."
66
- });
67
- pending.delete(id);
68
- }
69
- broadcastDeviceStatus(false);
70
- }
71
- /** @description Adds a controller and immediately reports current device presence to it. */
72
- function attachController(socket) {
73
- state$2.controllers.add(socket);
74
- sendDeviceStatus(socket, Boolean(state$2.device));
75
- for (const listener of controllerListeners) listener(true, state$2.controllers.size);
76
- }
77
- /**
78
- * @description Removes a controller and fails any of its in-flight evals. Twin of
79
- * {@link attachController}.
80
- * @param socket The controller that closed.
81
- */
82
- function detachController(socket) {
83
- state$2.controllers.delete(socket);
84
- for (const [id, entry] of pending) {
85
- if (entry.deliver !== controllerDelivery.get(socket)) continue;
86
- clearTimeout(entry.timer);
87
- pending.delete(id);
88
- }
89
- controllerDelivery.delete(socket);
90
- for (const listener of controllerListeners) listener(false, state$2.controllers.size);
91
- }
92
- /**
93
- * @description Runs code on the device on behalf of a controller socket, delivering the id-correlated
94
- * result back over that same socket. Sends an error frame instead if no device is connected.
95
- * @param socket The controller that issued the request.
96
- * @param id The controller-chosen correlation id, echoed on the result.
97
- * @param code The JavaScript to evaluate on the device.
98
- */
99
- function evaluateForController(socket, id, code) {
100
- const deliver = getControllerDelivery(socket);
101
- for (const listener of evalListeners) listener(code);
102
- dispatch(id, code, (result) => {
103
- for (const listener of resultListeners) listener(result);
104
- deliver(result);
105
- });
106
- }
107
- /**
108
- * @description Runs code on the device on behalf of an in-process caller (the terminal REPL),
109
- * resolving with the device's answer. Rejects if no device is connected or it doesn't answer within
110
- * {@link EVAL_TIMEOUT_MS}.
111
- * @param code The JavaScript to evaluate on the device.
112
- * @returns The device's {@link EvalResult}.
113
- */
114
- function evaluate(code) {
115
- return new Promise((resolve) => {
116
- dispatch(crypto.randomUUID(), code, resolve);
117
- });
118
- }
119
- function dispatch(id, code, deliver) {
120
- if (!state$2.device) {
121
- deliver({
122
- type: "eval-result",
123
- id,
124
- ok: false,
125
- error: "No device is connected to the debugger."
126
- });
127
- return;
128
- }
129
- const timer = setTimeout(() => {
130
- pending.delete(id);
131
- deliver({
132
- type: "eval-result",
133
- id,
134
- ok: false,
135
- error: `Evaluation timed out after ${EVAL_TIMEOUT_MS}ms.`
136
- });
137
- }, EVAL_TIMEOUT_MS);
138
- pending.set(id, {
139
- deliver,
140
- timer
141
- });
142
- state$2.device.send(JSON.stringify({
143
- type: "eval",
144
- id,
145
- code
146
- }));
147
- }
148
- /**
149
- * @description Routes a single parsed frame arriving from the device: an eval result settles its
150
- * pending request, a log line fans out to controllers and local listeners.
151
- * @param message The framed message received from the device.
152
- */
153
- function handleDeviceMessage$1(message) {
154
- if (message.type === "eval-result") {
155
- const entry = pending.get(message.id);
156
- if (!entry) return;
157
- clearTimeout(entry.timer);
158
- pending.delete(message.id);
159
- entry.deliver(message);
160
- return;
161
- }
162
- if (message.type === "log") {
163
- for (const socket of state$2.controllers) socket.send(JSON.stringify(message));
164
- for (const listener of logListeners) listener(message);
165
- }
166
- }
167
- /** @description Subscribes to device log lines; returns an unsubscribe function. */
168
- function onLog(listener) {
169
- logListeners.add(listener);
170
- return () => void logListeners.delete(listener);
171
- }
172
- /** @description Subscribes to code a controller (e.g. the MCP) sends to evaluate; returns an unsubscribe function. */
173
- function onControllerEval(listener) {
174
- evalListeners.add(listener);
175
- return () => void evalListeners.delete(listener);
176
- }
177
- /** @description Subscribes to the results of controller evals; returns an unsubscribe function. */
178
- function onControllerResult(listener) {
179
- resultListeners.add(listener);
180
- return () => void resultListeners.delete(listener);
181
- }
182
- /**
183
- * @description Subscribes to controller (MCP) connect/disconnect transitions, with the resulting
184
- * controller count; returns an unsubscribe function.
185
- */
186
- function onController(listener) {
187
- controllerListeners.add(listener);
188
- return () => void controllerListeners.delete(listener);
189
- }
190
- const controllerDelivery = /* @__PURE__ */ new Map();
191
- function getControllerDelivery(socket) {
192
- let deliver = controllerDelivery.get(socket);
193
- if (deliver) return deliver;
194
- deliver = (result) => socket.send(JSON.stringify(result));
195
- controllerDelivery.set(socket, deliver);
196
- return deliver;
197
- }
198
- function sendDeviceStatus(socket, connected) {
199
- socket.send(JSON.stringify({
200
- type: "device-status",
201
- connected
202
- }));
203
- }
204
- function broadcastDeviceStatus(connected) {
205
- for (const socket of state$2.controllers) sendDeviceStatus(socket, connected);
206
- for (const listener of deviceListeners) listener(connected);
207
- }
208
- //#endregion
209
- //#region src/lib/repl.ts
210
- const state$1 = {
211
- repl: null,
212
- currentInput: ""
213
- };
214
- function setupReplListeners() {
215
- terminal.on("key", (name, ...t) => {
216
- if (name !== "CTRL_C") return;
217
- process.exit(0);
218
- });
219
- }
220
- async function takeReplInput() {
221
- abortCurrentReplInput();
222
- terminal.bold.cyan("» ");
223
- state$1.repl = terminal.inputField({
224
- minLength: 1,
225
- history
226
- }, (error, input) => {
227
- if (error) return console.error(error);
228
- if (!input) return;
229
- state$1.currentInput = input;
230
- abortCurrentReplInput({ deletePrompt: true });
231
- terminal.white.bold(`» ${input}\n`);
232
- addHistoryItem(input);
233
- evaluate(input).then((result) => {
234
- if (result.ok) terminal.gray(`« ${result.value}\n`);
235
- else terminal.red(`« ${result.error}\n`);
236
- takeReplInput();
237
- });
238
- });
239
- await state$1.repl.promise;
240
- }
241
- function abortCurrentReplInput({ deletePrompt = false, deleteCurrentInput = false } = {}) {
242
- if (state$1.repl) {
243
- state$1.repl.abort();
244
- state$1.repl = null;
245
- if (deletePrompt) terminal.deleteLine(-1);
246
- if (deleteCurrentInput) state$1.currentInput = "";
247
- }
248
- }
249
- //#endregion
250
- //#region src/lib/format-log.ts
251
- /**
252
- * Reformats a log line as Hermes hands it to `nativeLoggingHook`. Hermes inspects each `console.*`
253
- * argument and joins them with `, ` — quoting string args (`'a', 'b', { x: 1 }`). That reads badly
254
- * in the debugger terminal, so we split on the top-level commas Hermes inserted, unquote the plain
255
- * string segments, and rejoin with spaces — leaving inspected objects/arrays untouched.
256
- *
257
- * This is best-effort: it only rewrites what parses cleanly as Hermes's join format. A segment it
258
- * can't confidently classify is left exactly as received, so worst case a line is unchanged, never
259
- * mangled.
260
- */
261
- /**
262
- * @description Cleans a Hermes-joined console line into space-separated, unquoted output.
263
- * @param message The raw `nativeLoggingHook` message string.
264
- * @returns The reformatted line, or the original if it doesn't look like a Hermes join.
265
- */
266
- function formatLog(message) {
267
- const segments = splitTopLevel(message);
268
- const line = segments ? segments.map(unquoteSegment).join(" ") : message;
269
- return line.startsWith("» ") ? line.slice(2) : line;
270
- }
271
- /**
272
- * Splits on the commas Hermes inserts between arguments — those at nesting depth zero and outside
273
- * any string literal. Returns null if the delimiters are unbalanced (not a Hermes join we trust).
274
- */
275
- function splitTopLevel(input) {
276
- const segments = [];
277
- let depth = 0;
278
- let inString = false;
279
- let start = 0;
280
- for (let i = 0, len = input.length; i < len; i++) {
281
- const char = input[i];
282
- if (inString) {
283
- if (char === "\\") {
284
- i++;
285
- continue;
286
- }
287
- if (char === "'") inString = false;
288
- continue;
289
- }
290
- switch (char) {
291
- case "'":
292
- inString = true;
293
- break;
294
- case "{":
295
- case "[":
296
- case "(":
297
- depth++;
298
- break;
299
- case "}":
300
- case "]":
301
- case ")":
302
- depth--;
303
- break;
304
- case ",":
305
- if (depth === 0) {
306
- segments.push(input.slice(start, i));
307
- if (input[i + 1] === " ") i++;
308
- start = i + 1;
309
- }
310
- break;
311
- }
312
- }
313
- if (depth !== 0 || inString) return null;
314
- segments.push(input.slice(start));
315
- return segments;
316
- }
317
- /** Unwraps a fully single-quoted segment (`'text'`) into `text`; leaves anything else untouched. */
318
- function unquoteSegment(segment) {
319
- const trimmed = segment.trim();
320
- if (trimmed.length < 2 || trimmed[0] !== "'" || trimmed[trimmed.length - 1] !== "'") return trimmed;
321
- return trimmed.slice(1, -1).replace(/\\'/g, "'");
322
- }
323
- //#endregion
324
- //#region src/lib/banner.ts
325
- /**
326
- * Renders the debugger's startup banner: a rounded box, tinted in the brand pink, that adapts to the
327
- * terminal's width. The box shrinks to fit narrow terminals and caps at a comfortable reading width
328
- * on wide ones; when the terminal is too narrow for a box, it falls back to plain starred lines.
329
- */
330
- /** Brand pink used for the banner frame and title. */
331
- const PINK = "#ff5fbf";
332
- /** Dim grey for secondary label text inside the box. */
333
- const GREY = "#9ca3af";
334
- /** The banner never grows past this many columns, however wide the terminal is. */
335
- const MAX_WIDTH = 72;
336
- /** Below this inner width there's no room for a box; fall back to plain lines. */
337
- const MIN_BOX_WIDTH = 24;
338
- /** The horizontal padding between the box border and its content. */
339
- const PADDING = 2;
340
- /**
341
- * @description Prints the startup banner for the given connection targets, sized to the terminal.
342
- * @param deviceAddress The `host:port` a device connects to.
343
- * @param mcpAddress The `ws://…?mcp` URL an MCP client connects to.
344
- */
345
- function printBanner(deviceAddress, mcpAddress) {
346
- const title = "Unbound's Debugger";
347
- const lines = [{
348
- label: "Devices",
349
- value: deviceAddress
350
- }, {
351
- label: "MCP",
352
- value: mcpAddress
353
- }];
354
- const longest = Math.max(18, ...lines.map((l) => rowText(l).length));
355
- const available = terminalWidth() - 2 - PADDING * 2;
356
- const inner = Math.min(longest, MAX_WIDTH, available);
357
- if (inner < MIN_BOX_WIDTH || inner < longest) {
358
- printFallback(title, lines);
359
- return;
360
- }
361
- printBox(title, lines, inner);
362
- }
363
- /** The plain (unstyled) text of a connection row, used for width maths. */
364
- function rowText(line) {
365
- return `${line.label.padEnd(8)}${line.value}`;
366
- }
367
- function printBox(title, lines, inner) {
368
- const pad = " ".repeat(PADDING);
369
- const horizontal = "─".repeat(inner + PADDING * 2);
370
- rgb(PINK, `╭${horizontal}╮\n`);
371
- rgb(PINK, `│${pad}`);
372
- boldRgb(PINK, center(title, inner));
373
- rgb(PINK, `${pad}│\n`);
374
- rgb(PINK, `├${horizontal}┤\n`);
375
- for (const line of lines) {
376
- const label = line.label.padEnd(8);
377
- const filler = " ".repeat(Math.max(inner - rowText(line).length, 0));
378
- rgb(PINK, `│${pad}`);
379
- boldRgb(PINK, label);
380
- rgb(GREY, line.value);
381
- terminal(filler);
382
- rgb(PINK, `${pad}│\n`);
383
- }
384
- rgb(PINK, `╰${horizontal}╯\n`);
385
- }
386
- function printFallback(title, lines) {
387
- boldRgb(PINK, `✦ ${title}\n`);
388
- for (const line of lines) {
389
- rgb(PINK, ` ${line.label.padEnd(8)}`);
390
- rgb(GREY, `${line.value}\n`);
391
- }
392
- }
393
- /** The usable terminal width: terminal-kit's when it's a real TTY, else stdout's, else a default. */
394
- function terminalWidth() {
395
- const fromKit = terminal.width;
396
- if (typeof fromKit === "number" && Number.isFinite(fromKit)) return fromKit;
397
- const fromStdout = process.stdout.columns;
398
- if (typeof fromStdout === "number" && fromStdout > 0) return fromStdout;
399
- return 80;
400
- }
401
- function center(text, width) {
402
- const total = Math.max(width - text.length, 0);
403
- const left = Math.floor(total / 2);
404
- return " ".repeat(left) + text + " ".repeat(total - left);
405
- }
406
- function rgb(hex, text) {
407
- terminal.colorRgbHex(hex, text);
408
- }
409
- function boldRgb(hex, text) {
410
- terminal.bold.colorRgbHex(hex, text);
411
- }
412
- //#endregion
413
- //#region src/lib/cli.ts
414
- const argv = cli({
415
- name: "ub-debugger",
416
- flags: { port: {
417
- type: Number,
418
- description: "The port number to host the debugger on.",
419
- default: 9090
420
- } }
421
- });
422
- //#endregion
423
- //#region src/lib/ws.ts
424
- const state = { isInitialized: false };
425
- const MESSAGE_LEVELS = {
426
- 1: terminal.gray,
427
- 2: terminal.yellow,
428
- 3: terminal.red
429
- };
430
- /** The glyph that prefixes every bridge lifecycle line, so system events read as one family. */
431
- const SYSTEM_GLYPH = "✦";
432
- /** Hex colours for the system banners: pink for MCP, green/red for the device, gray for startup. */
433
- const SYSTEM_COLORS = {
434
- pink: "#ff5fbf",
435
- green: "#3cb371",
436
- red: "#ff6b6b",
437
- gray: "#9ca3af"
438
- };
439
- /**
440
- * @description Prints a bold, glyph-prefixed banner line for a bridge lifecycle event, tinted with
441
- * the given hex colour. Uses terminal-kit's runtime `colorRgbHex`, which `@types/terminal-kit`
442
- * doesn't declare, so the chain is reached through a narrow cast.
443
- * @param color The banner colour, keyed into {@link SYSTEM_COLORS}.
444
- * @param text The line to print (the glyph is prepended and a trailing newline added).
445
- */
446
- function system(color, text) {
447
- terminal.bold.colorRgbHex(SYSTEM_COLORS[color], `${SYSTEM_GLYPH} ${text}\n`);
448
- }
449
- /**
450
- * @description Boots the bridge: the phone-facing WebSocket server, the shared session routing, and
451
- * the terminal REPL as an in-process controller. A device connects plainly; controllers (MCP
452
- * clients) connect with `?mcp` and share the single device socket.
453
- */
454
- function setupWebSocket() {
455
- if (state.isInitialized) return;
456
- state.isInitialized = true;
457
- Bun.serve({
458
- port: argv.flags.port,
459
- fetch(request, server) {
460
- if (new URL(request.url).searchParams.has("mcp")) {
461
- server.upgrade(request, { data: { role: "controller" } });
462
- return;
463
- }
464
- if (state$2.device) return new Response(JSON.stringify({ error: "A device is already connected." }), { status: 503 });
465
- server.upgrade(request, { data: { role: "device" } });
466
- },
467
- websocket: {
468
- open(ws) {
469
- if (ws.data.role === "controller") {
470
- attachController(ws);
471
- return;
472
- }
473
- attachDevice(ws);
474
- abortCurrentReplInput({ deletePrompt: true });
475
- system("green", "Device connected");
476
- setupReplListeners();
477
- takeReplInput();
478
- },
479
- message(ws, message) {
480
- if (typeof message !== "string") return;
481
- if (ws.data.role === "controller") {
482
- handleControllerMessage(ws, message);
483
- return;
484
- }
485
- handleDeviceMessage(message);
486
- },
487
- close(ws) {
488
- if (ws.data.role === "controller") {
489
- detachController(ws);
490
- return;
491
- }
492
- detachDevice(ws);
493
- abortCurrentReplInput({
494
- deletePrompt: true,
495
- deleteCurrentInput: true
496
- });
497
- system("red", "Device disconnected");
498
- }
499
- }
500
- });
501
- onLog((log) => {
502
- abortCurrentReplInput({ deletePrompt: true });
503
- MESSAGE_LEVELS[log.level](`« ${formatLog(stripEscapeSequences(log.message))}\n`);
504
- takeReplInput();
505
- });
506
- onControllerEval((code) => {
507
- abortCurrentReplInput({ deletePrompt: true });
508
- terminal.magenta(`» [mcp] ${code}\n`);
509
- takeReplInput();
510
- });
511
- onControllerResult((result) => {
512
- abortCurrentReplInput({ deletePrompt: true });
513
- if (result.ok) terminal.gray(`« ${result.value}\n`);
514
- else terminal.red(`« ${result.error}\n`);
515
- takeReplInput();
516
- });
517
- onController((connected, count) => {
518
- abortCurrentReplInput({ deletePrompt: true });
519
- const plural = count === 1 ? "client" : "clients";
520
- system("pink", connected ? `MCP connected - ${count} ${plural} attached` : `MCP disconnected - ${count} ${plural} attached`);
521
- takeReplInput();
522
- });
523
- printBanner(`${getLocalAddress()}:${argv.flags.port}`, `ws://127.0.0.1:${argv.flags.port}?mcp`);
524
- }
525
- function handleDeviceMessage(raw) {
526
- const message = parseMessage(raw);
527
- if (!message) {
528
- system("red", "Parsing failed on message from device");
529
- return;
530
- }
531
- handleDeviceMessage$1(message);
532
- }
533
- function handleControllerMessage(ws, raw) {
534
- const message = parseMessage(raw);
535
- if (message?.type !== "eval") return;
536
- evaluateForController(ws, message.id, message.code);
537
- }
538
- function getLocalAddress() {
539
- const interfaces = os.networkInterfaces();
540
- for (const ifaces of Object.values(interfaces)) {
541
- if (!ifaces) continue;
542
- for (const iface of ifaces) if (iface.family === "IPv4" && !iface.internal) return iface.address;
543
- }
544
- }
545
- //#endregion
546
- //#region src/index.ts
547
- await loadHistory();
548
- await setupWebSocket();
549
- //#endregion
550
- export {};