@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 +2 -0
- package/package.json +12 -25
- package/src/index.ts +7 -0
- package/src/lib/cli.ts +15 -0
- package/src/lib/history.ts +29 -0
- package/src/lib/repl.ts +47 -0
- package/src/lib/ws.ts +97 -0
- package/tsconfig.json +29 -0
- package/dist/index.d.mts +0 -1
- package/dist/index.mjs +0 -550
package/TODO.md
ADDED
package/package.json
CHANGED
|
@@ -1,34 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@unbound-app/debugger",
|
|
3
|
-
"
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
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": "
|
|
8
|
+
"ub-debugger": "src/index.ts"
|
|
17
9
|
},
|
|
18
|
-
"
|
|
19
|
-
"
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
"
|
|
23
|
-
|
|
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
package/src/lib/cli.ts
ADDED
|
@@ -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
|
+
}
|
package/src/lib/repl.ts
ADDED
|
@@ -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 {};
|