@digital-gravy/etch-connector 0.1.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/LICENSE +26 -0
- package/README.md +253 -0
- package/dist/bridge-server.js +242 -0
- package/dist/cdp-client.js +285 -0
- package/dist/cli.js +452 -0
- package/dist/control-server.js +108 -0
- package/dist/index.js +11 -0
- package/dist/manual.js +2 -0
- package/dist/protocol.js +38 -0
- package/package.json +70 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* etch-connector CLI — the thing an AI harness (Claude Code, Cowork, …) shells
|
|
4
|
+
* out to. Three commands:
|
|
5
|
+
*
|
|
6
|
+
* etch-connector serve start the bridge daemon (run this once)
|
|
7
|
+
* etch-connector tabs list connected Etch tabs
|
|
8
|
+
* etch-connector eval [code] run a script in a tab; value → stdout
|
|
9
|
+
*
|
|
10
|
+
* `eval` prints forwarded console output to *stderr* and the script's return
|
|
11
|
+
* value as JSON to *stdout*, so an agent can capture stdout as the clean result.
|
|
12
|
+
* Exit codes: 0 ok · 2 the script threw · 1 everything else (no tab, timeout…).
|
|
13
|
+
*/
|
|
14
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
15
|
+
import { BridgeServer } from './bridge-server.js';
|
|
16
|
+
import { createControlServer, controlBaseUrl } from './control-server.js';
|
|
17
|
+
import { CdpClient, CdpError, CdpEvalError } from './cdp-client.js';
|
|
18
|
+
import { DEFAULT_CONTROL_PORT, DEFAULT_HOST, DEFAULT_WS_PORT } from './protocol.js';
|
|
19
|
+
const DEFAULT_CDP_PORT = 9222;
|
|
20
|
+
/** Long flags that never take a value, so they don't swallow the next token. */
|
|
21
|
+
const BOOLEAN_FLAGS = new Set([
|
|
22
|
+
'cdp',
|
|
23
|
+
'full',
|
|
24
|
+
'jpeg',
|
|
25
|
+
'json',
|
|
26
|
+
'unsafe',
|
|
27
|
+
'safe',
|
|
28
|
+
'help',
|
|
29
|
+
'freeze'
|
|
30
|
+
]);
|
|
31
|
+
function parseArgs(argv) {
|
|
32
|
+
const command = argv[0] ?? 'help';
|
|
33
|
+
const positional = [];
|
|
34
|
+
const flags = {};
|
|
35
|
+
for (let i = 1; i < argv.length; i++) {
|
|
36
|
+
const arg = argv[i];
|
|
37
|
+
if (arg.startsWith('--')) {
|
|
38
|
+
// Support --key=value (e.g. --freeze=false) explicitly.
|
|
39
|
+
const eq = arg.indexOf('=');
|
|
40
|
+
if (eq !== -1) {
|
|
41
|
+
flags[arg.slice(2, eq)] = arg.slice(eq + 1);
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
const key = arg.slice(2);
|
|
45
|
+
const next = argv[i + 1];
|
|
46
|
+
if (BOOLEAN_FLAGS.has(key)) {
|
|
47
|
+
flags[key] = true;
|
|
48
|
+
}
|
|
49
|
+
else if (next !== undefined && !next.startsWith('-')) {
|
|
50
|
+
flags[key] = next;
|
|
51
|
+
i++;
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
flags[key] = true;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
else if (arg.startsWith('-') && arg.length === 2) {
|
|
58
|
+
const map = {
|
|
59
|
+
t: 'tab',
|
|
60
|
+
f: 'file',
|
|
61
|
+
p: 'port',
|
|
62
|
+
s: 'selector',
|
|
63
|
+
o: 'out'
|
|
64
|
+
};
|
|
65
|
+
const key = map[arg.slice(1)] ?? arg.slice(1);
|
|
66
|
+
const next = argv[i + 1];
|
|
67
|
+
if (next !== undefined && !next.startsWith('-')) {
|
|
68
|
+
flags[key] = next;
|
|
69
|
+
i++;
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
flags[key] = true;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
positional.push(arg);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return { command, positional, flags };
|
|
80
|
+
}
|
|
81
|
+
function num(flag, fallback) {
|
|
82
|
+
if (typeof flag === 'string') {
|
|
83
|
+
const n = Number(flag);
|
|
84
|
+
if (!Number.isNaN(n))
|
|
85
|
+
return n;
|
|
86
|
+
}
|
|
87
|
+
return fallback;
|
|
88
|
+
}
|
|
89
|
+
function str(flag) {
|
|
90
|
+
return typeof flag === 'string' ? flag : undefined;
|
|
91
|
+
}
|
|
92
|
+
/** Resolve a boolean flag, honoring bare `--flag` and `--flag=false|true`. */
|
|
93
|
+
function bool(flag, fallback) {
|
|
94
|
+
if (flag === undefined)
|
|
95
|
+
return fallback;
|
|
96
|
+
if (typeof flag === 'boolean')
|
|
97
|
+
return flag;
|
|
98
|
+
return !/^(false|0|no|off)$/i.test(flag);
|
|
99
|
+
}
|
|
100
|
+
function readStdin() {
|
|
101
|
+
return new Promise((resolve) => {
|
|
102
|
+
let data = '';
|
|
103
|
+
process.stdin.setEncoding('utf8');
|
|
104
|
+
process.stdin.on('data', (chunk) => (data += chunk));
|
|
105
|
+
process.stdin.on('end', () => resolve(data));
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
async function runServe(flags) {
|
|
109
|
+
const wsHost = str(flags['ws-host']) ?? DEFAULT_HOST;
|
|
110
|
+
const wsPort = num(flags['ws-port'], DEFAULT_WS_PORT);
|
|
111
|
+
const controlHost = str(flags['control-host']) ?? DEFAULT_HOST;
|
|
112
|
+
const controlPort = num(flags['control-port'], DEFAULT_CONTROL_PORT);
|
|
113
|
+
const defaultMode = flags['unsafe'] ? 'unsafe' : 'safe';
|
|
114
|
+
const bridge = new BridgeServer({ host: wsHost, port: wsPort });
|
|
115
|
+
bridge.on('connect', (tab) => console.error(`[etch-connector] + tab "${tab.name}" (${tab.url})`));
|
|
116
|
+
bridge.on('disconnect', (tab) => console.error(`[etch-connector] - tab "${tab.name}"`));
|
|
117
|
+
const { port: boundWsPort } = await bridge.listen();
|
|
118
|
+
const control = createControlServer(bridge, { defaultMode });
|
|
119
|
+
await new Promise((resolve) => control.listen(controlPort, controlHost, resolve));
|
|
120
|
+
// Periodic liveness probe so dead tabs that never fired `close` get pruned.
|
|
121
|
+
const heartbeat = setInterval(() => bridge.pingAll(), 30_000);
|
|
122
|
+
heartbeat.unref();
|
|
123
|
+
console.error(`[etch-connector] bridge ws://${wsHost}:${boundWsPort}`);
|
|
124
|
+
console.error(`[etch-connector] control ${controlBaseUrl({ host: controlHost, port: controlPort })}`);
|
|
125
|
+
console.error(`[etch-connector] default mode: ${defaultMode}${defaultMode === 'unsafe' ? ' ⚠ scripts get full access to the page' : ' (scripts see only window.etch + JS built-ins)'}`);
|
|
126
|
+
console.error('[etch-connector] In an Etch builder tab, run:');
|
|
127
|
+
console.error(`[etch-connector] etch.connectTo('my-tab'${boundWsPort === DEFAULT_WS_PORT ? '' : `, { port: ${boundWsPort} }`})`);
|
|
128
|
+
console.error('[etch-connector] then drive it with: etch-connector eval -t my-tab "return window.etch.apiVersion"');
|
|
129
|
+
const shutdown = () => {
|
|
130
|
+
clearInterval(heartbeat);
|
|
131
|
+
control.close();
|
|
132
|
+
void bridge.close().then(() => process.exit(0));
|
|
133
|
+
};
|
|
134
|
+
process.on('SIGINT', shutdown);
|
|
135
|
+
process.on('SIGTERM', shutdown);
|
|
136
|
+
}
|
|
137
|
+
function controlUrlFromFlags(flags) {
|
|
138
|
+
return controlBaseUrl({
|
|
139
|
+
host: str(flags['control-host']),
|
|
140
|
+
port: num(flags['control-port'], DEFAULT_CONTROL_PORT)
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* CDP (Chrome remote-debugging) config when the caller opted in via `--cdp`,
|
|
145
|
+
* `--cdp-port`, `--cdp-host`, or `--remote-debugging-port`; otherwise null.
|
|
146
|
+
*/
|
|
147
|
+
function cdpFromFlags(flags) {
|
|
148
|
+
const rdp = flags['remote-debugging-port'];
|
|
149
|
+
const opted = Boolean(flags['cdp']) ||
|
|
150
|
+
rdp !== undefined ||
|
|
151
|
+
flags['cdp-port'] !== undefined ||
|
|
152
|
+
flags['cdp-host'] !== undefined;
|
|
153
|
+
if (!opted)
|
|
154
|
+
return null;
|
|
155
|
+
return {
|
|
156
|
+
host: str(flags['cdp-host']) ?? DEFAULT_HOST,
|
|
157
|
+
port: num(flags['cdp-port'], num(rdp, DEFAULT_CDP_PORT))
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
/** Connect a CDP client to the matched target, run `fn`, then always close. */
|
|
161
|
+
async function withCdp(cdp, match, fn) {
|
|
162
|
+
const client = new CdpClient(cdp);
|
|
163
|
+
try {
|
|
164
|
+
await client.connect(match);
|
|
165
|
+
return await fn(client);
|
|
166
|
+
}
|
|
167
|
+
finally {
|
|
168
|
+
await client.close();
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Shown when no CDP endpoint can be reached. Addressed to the agent/LLM: it
|
|
173
|
+
* must relay these steps to the user rather than act on them itself — launching
|
|
174
|
+
* a fresh browser here would not carry the user's WordPress/Etch login.
|
|
175
|
+
*/
|
|
176
|
+
function cdpLaunchHint(port) {
|
|
177
|
+
return [
|
|
178
|
+
'',
|
|
179
|
+
`No Chromium browser with remote debugging is open on port ${port}.`,
|
|
180
|
+
'',
|
|
181
|
+
'AGENT: do not launch a browser yourself — a fresh instance would not have',
|
|
182
|
+
"the user's WordPress/Etch session. Tell the user to start their own browser",
|
|
183
|
+
'with remote debugging enabled, then retry this command:',
|
|
184
|
+
'',
|
|
185
|
+
' 1. Fully quit the Chromium-family browser first (Chrome / Brave / Edge /',
|
|
186
|
+
" Chromium) — the flag is ignored if it's already running.",
|
|
187
|
+
' 2. Relaunch it with the remote-debugging flag:',
|
|
188
|
+
` Linux: google-chrome --remote-debugging-port=${port}`,
|
|
189
|
+
` macOS: open -a "Google Chrome" --args --remote-debugging-port=${port}`,
|
|
190
|
+
` Windows: chrome.exe --remote-debugging-port=${port}`,
|
|
191
|
+
' (Brave / Edge / Chromium work the same — just swap the binary.)',
|
|
192
|
+
' 3. Open the Etch builder tab and log in, then re-run this command.',
|
|
193
|
+
'',
|
|
194
|
+
'Firefox is not supported — CDP requires a Chromium-family browser.'
|
|
195
|
+
].join('\n');
|
|
196
|
+
}
|
|
197
|
+
/** Map a thrown CDP error to the right exit: 2 for script throws, 1 otherwise. */
|
|
198
|
+
function failCdp(err, port = DEFAULT_CDP_PORT) {
|
|
199
|
+
if (err instanceof CdpEvalError) {
|
|
200
|
+
console.error(`script error: ${err.message}`);
|
|
201
|
+
if (err.details)
|
|
202
|
+
console.error(err.details);
|
|
203
|
+
process.exit(2);
|
|
204
|
+
}
|
|
205
|
+
console.error(err instanceof CdpError ? err.message : String(err));
|
|
206
|
+
// The connection-failure message comes from CdpClient.listTargets/connect.
|
|
207
|
+
if (err instanceof CdpError &&
|
|
208
|
+
/cannot reach|ECONNREFUSED|failed to attach/i.test(err.message)) {
|
|
209
|
+
console.error(cdpLaunchHint(port));
|
|
210
|
+
}
|
|
211
|
+
process.exit(1);
|
|
212
|
+
}
|
|
213
|
+
async function runTabs(flags) {
|
|
214
|
+
const cdp = cdpFromFlags(flags);
|
|
215
|
+
if (cdp) {
|
|
216
|
+
const targets = await CdpClient.listTargets(cdp).catch((e) => failCdp(e, cdp.port));
|
|
217
|
+
if (flags['json']) {
|
|
218
|
+
process.stdout.write(`${JSON.stringify(targets, null, 2)}\n`);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (targets.length === 0) {
|
|
222
|
+
console.error('No Chrome page targets open.');
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
for (const t of targets)
|
|
226
|
+
process.stdout.write(`${t.title}\t${t.url}\n`);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
const res = await fetch(`${controlUrlFromFlags(flags)}/tabs`).catch((err) => {
|
|
230
|
+
throw new Error(`cannot reach the connector daemon — is \`etch-connector serve\` running? (${err})`);
|
|
231
|
+
});
|
|
232
|
+
const { tabs } = (await res.json());
|
|
233
|
+
if (flags['json']) {
|
|
234
|
+
process.stdout.write(`${JSON.stringify(tabs, null, 2)}\n`);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (tabs.length === 0) {
|
|
238
|
+
console.error('No Etch tabs connected.');
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
for (const tab of tabs) {
|
|
242
|
+
process.stdout.write(`${tab.name}\t${tab.hasEtch ? 'etch' : 'no-etch'}\t${tab.title}\t${tab.url}\n`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
async function gatherCode(positional, flags) {
|
|
246
|
+
const file = str(flags['file']);
|
|
247
|
+
if (file)
|
|
248
|
+
return readFileSync(file, 'utf8');
|
|
249
|
+
if (positional.length > 0)
|
|
250
|
+
return positional.join(' ');
|
|
251
|
+
return (await readStdin()).trim();
|
|
252
|
+
}
|
|
253
|
+
async function runEval(positional, flags) {
|
|
254
|
+
const code = await gatherCode(positional, flags);
|
|
255
|
+
if (!code) {
|
|
256
|
+
console.error('eval: no script provided (pass code as an argument, --file, or stdin)');
|
|
257
|
+
process.exit(1);
|
|
258
|
+
}
|
|
259
|
+
const timeoutMs = typeof flags['timeout'] === 'string'
|
|
260
|
+
? num(flags['timeout'], 30_000)
|
|
261
|
+
: undefined;
|
|
262
|
+
// CDP mode: eval over Chrome remote debugging (full window access, no daemon).
|
|
263
|
+
const cdp = cdpFromFlags(flags);
|
|
264
|
+
if (cdp) {
|
|
265
|
+
try {
|
|
266
|
+
const value = await withCdp(cdp, str(flags['tab']), (c) => c.eval(code, { timeoutMs }));
|
|
267
|
+
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
268
|
+
}
|
|
269
|
+
catch (err) {
|
|
270
|
+
failCdp(err, cdp.port);
|
|
271
|
+
}
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
// --unsafe / --safe override the daemon default; omitting both defers to it.
|
|
275
|
+
let mode;
|
|
276
|
+
if (flags['unsafe'])
|
|
277
|
+
mode = 'unsafe';
|
|
278
|
+
else if (flags['safe'])
|
|
279
|
+
mode = 'safe';
|
|
280
|
+
if (mode === 'unsafe') {
|
|
281
|
+
console.error('⚠ running in unsafe mode — the script has full access to the page');
|
|
282
|
+
}
|
|
283
|
+
const body = {
|
|
284
|
+
tab: str(flags['tab']),
|
|
285
|
+
code,
|
|
286
|
+
timeoutMs,
|
|
287
|
+
mode
|
|
288
|
+
};
|
|
289
|
+
const res = await fetch(`${controlUrlFromFlags(flags)}/eval`, {
|
|
290
|
+
method: 'POST',
|
|
291
|
+
headers: { 'content-type': 'application/json' },
|
|
292
|
+
body: JSON.stringify(body)
|
|
293
|
+
}).catch((err) => {
|
|
294
|
+
throw new Error(`cannot reach the connector daemon — is \`etch-connector serve\` running? (${err})`);
|
|
295
|
+
});
|
|
296
|
+
if (!res.ok) {
|
|
297
|
+
const err = (await res.json().catch(() => ({})));
|
|
298
|
+
console.error(`eval failed: ${err.error ?? res.statusText}`);
|
|
299
|
+
process.exit(1);
|
|
300
|
+
}
|
|
301
|
+
const result = (await res.json());
|
|
302
|
+
// Forwarded console output → stderr, so stdout stays clean for the value.
|
|
303
|
+
for (const log of result.logs) {
|
|
304
|
+
const text = log.args
|
|
305
|
+
.map((a) => (typeof a === 'string' ? a : JSON.stringify(a)))
|
|
306
|
+
.join(' ');
|
|
307
|
+
console.error(`[${log.level}] ${text}`);
|
|
308
|
+
}
|
|
309
|
+
if (!result.ok) {
|
|
310
|
+
console.error(`script error: ${result.error}`);
|
|
311
|
+
if (result.stack)
|
|
312
|
+
console.error(result.stack);
|
|
313
|
+
process.exit(2);
|
|
314
|
+
}
|
|
315
|
+
process.stdout.write(`${JSON.stringify(result.value, null, 2)}\n`);
|
|
316
|
+
}
|
|
317
|
+
/** `shot` / `html` / `computed` are CDP-only; default to localhost:9222. */
|
|
318
|
+
function requireCdp(flags) {
|
|
319
|
+
return (cdpFromFlags(flags) ?? { host: DEFAULT_HOST, port: DEFAULT_CDP_PORT });
|
|
320
|
+
}
|
|
321
|
+
async function runShot(flags) {
|
|
322
|
+
const cdp = requireCdp(flags);
|
|
323
|
+
const selector = str(flags['selector']);
|
|
324
|
+
const full = Boolean(flags['full']);
|
|
325
|
+
const format = flags['jpeg'] ? 'jpeg' : 'png';
|
|
326
|
+
const freeze = bool(flags['freeze'], true); // default on (animations → deterministic)
|
|
327
|
+
const out = str(flags['out']) ?? `etch-shot.${format === 'jpeg' ? 'jpg' : 'png'}`;
|
|
328
|
+
try {
|
|
329
|
+
const buf = await withCdp(cdp, str(flags['tab']), (c) => c.screenshot({ selector, full, format, freeze }));
|
|
330
|
+
writeFileSync(out, buf);
|
|
331
|
+
const scope = selector
|
|
332
|
+
? ` — clipped to ${selector}`
|
|
333
|
+
: full
|
|
334
|
+
? ' — full page'
|
|
335
|
+
: ' — viewport';
|
|
336
|
+
console.error(`wrote ${out} (${buf.length} bytes)${scope}${freeze ? ' (frozen)' : ''}`);
|
|
337
|
+
}
|
|
338
|
+
catch (err) {
|
|
339
|
+
failCdp(err, cdp.port);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
async function runHtml(positional, flags) {
|
|
343
|
+
const selector = str(flags['selector']) ?? positional[0];
|
|
344
|
+
if (!selector) {
|
|
345
|
+
console.error('html: pass a CSS selector (-s/--selector or positional)');
|
|
346
|
+
process.exit(1);
|
|
347
|
+
}
|
|
348
|
+
const cdp = requireCdp(flags);
|
|
349
|
+
try {
|
|
350
|
+
const html = await withCdp(cdp, str(flags['tab']), (c) => c.outerHtml(selector));
|
|
351
|
+
process.stdout.write(`${html}\n`);
|
|
352
|
+
}
|
|
353
|
+
catch (err) {
|
|
354
|
+
failCdp(err, cdp.port);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
async function runComputed(positional, flags) {
|
|
358
|
+
const selector = str(flags['selector']) ?? positional[0];
|
|
359
|
+
if (!selector) {
|
|
360
|
+
console.error('computed: pass a CSS selector (-s/--selector or positional)');
|
|
361
|
+
process.exit(1);
|
|
362
|
+
}
|
|
363
|
+
const props = str(flags['props'])
|
|
364
|
+
?.split(',')
|
|
365
|
+
.map((p) => p.trim())
|
|
366
|
+
.filter(Boolean);
|
|
367
|
+
const cdp = requireCdp(flags);
|
|
368
|
+
try {
|
|
369
|
+
const style = await withCdp(cdp, str(flags['tab']), (c) => c.computedStyle(selector, props));
|
|
370
|
+
process.stdout.write(`${JSON.stringify(style, null, 2)}\n`);
|
|
371
|
+
}
|
|
372
|
+
catch (err) {
|
|
373
|
+
failCdp(err, cdp.port);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
function printHelp() {
|
|
377
|
+
console.error(`etch-connector — drive a live Etch tab from the command line
|
|
378
|
+
|
|
379
|
+
Usage:
|
|
380
|
+
etch-connector serve [--ws-port ${DEFAULT_WS_PORT}] [--control-port ${DEFAULT_CONTROL_PORT}] [--ws-host ${DEFAULT_HOST}] [--unsafe]
|
|
381
|
+
etch-connector tabs [--json] [--control-port ${DEFAULT_CONTROL_PORT}] [--cdp]
|
|
382
|
+
etch-connector eval [code] [-t|--tab name] [-f|--file path] [--timeout ms] [--safe|--unsafe|--cdp]
|
|
383
|
+
etch-connector shot [-t name] [-s|--selector css] [--full] [--jpeg] [-o|--out file] [--freeze=false] (CDP)
|
|
384
|
+
etch-connector html <selector> [-t name] (CDP)
|
|
385
|
+
etch-connector computed <selector> [-t name] [--props a,b,c] (CDP)
|
|
386
|
+
|
|
387
|
+
Two backends:
|
|
388
|
+
bridge (default) the tab dials in via etch.connectTo(); page-sandboxed.
|
|
389
|
+
safe = script sees only "etch" + JS built-ins (default)
|
|
390
|
+
unsafe = full page access (--unsafe per eval, or serve --unsafe)
|
|
391
|
+
cdp connect OUT to a Chrome started with --remote-debugging-port=${DEFAULT_CDP_PORT}.
|
|
392
|
+
No daemon. Runs at DevTools privilege: full "window", plus screenshots,
|
|
393
|
+
rendered HTML, and computed styles. Opt in with --cdp (or --cdp-port /
|
|
394
|
+
--remote-debugging-port / --cdp-host). Target a tab with -t <url|title substring>.
|
|
395
|
+
|
|
396
|
+
AGENT: before running anything, ask the user how they want to connect — bridge
|
|
397
|
+
safe (API only), bridge unsafe (full page access), or cdp (DevTools privilege).
|
|
398
|
+
Each grants more reach than the last; don't assume — let the user choose.
|
|
399
|
+
|
|
400
|
+
Examples:
|
|
401
|
+
etch-connector serve
|
|
402
|
+
etch-connector tabs
|
|
403
|
+
etch-connector eval -t homepage "return etch.apiVersion"
|
|
404
|
+
echo "return await etch.blocks.getTree()" | etch-connector eval -t homepage
|
|
405
|
+
etch-connector eval -t homepage -f ./script.js
|
|
406
|
+
# CDP backend (Chrome launched with --remote-debugging-port=${DEFAULT_CDP_PORT}):
|
|
407
|
+
etch-connector tabs --cdp
|
|
408
|
+
etch-connector eval --cdp -t etch "return document.title"
|
|
409
|
+
etch-connector shot --cdp -t etch -s .orbit -o orbit.png
|
|
410
|
+
etch-connector computed --cdp -t etch -s .orbit__chip --props transform,animation-duration
|
|
411
|
+
|
|
412
|
+
The eval body runs as an async function, so it may 'return' a value and use
|
|
413
|
+
top-level 'await'. In bridge safe mode reference the API as "etch.*" ("window"
|
|
414
|
+
is withheld); in cdp mode the full page is available.
|
|
415
|
+
|
|
416
|
+
Docs (AGENT: pull these BEFORE writing scripts against etch.* — the API surface
|
|
417
|
+
changes and training data may be stale):
|
|
418
|
+
Context7 MCP (preferred) resolve-library-id "Etch" → query-docs for the
|
|
419
|
+
specific call (e.g. etch.blocks.create).
|
|
420
|
+
Manual fallback https://docs.etchwp.com/public-api (window.etch.*)
|
|
421
|
+
https://docs.etchwp.com (everything else)
|
|
422
|
+
See AGENTS.md for project conventions.`);
|
|
423
|
+
}
|
|
424
|
+
async function main() {
|
|
425
|
+
const { command, positional, flags } = parseArgs(process.argv.slice(2));
|
|
426
|
+
switch (command) {
|
|
427
|
+
case 'serve':
|
|
428
|
+
return runServe(flags);
|
|
429
|
+
case 'tabs':
|
|
430
|
+
return runTabs(flags);
|
|
431
|
+
case 'eval':
|
|
432
|
+
return runEval(positional, flags);
|
|
433
|
+
case 'shot':
|
|
434
|
+
return runShot(flags);
|
|
435
|
+
case 'html':
|
|
436
|
+
return runHtml(positional, flags);
|
|
437
|
+
case 'computed':
|
|
438
|
+
return runComputed(positional, flags);
|
|
439
|
+
case 'help':
|
|
440
|
+
case '--help':
|
|
441
|
+
case '-h':
|
|
442
|
+
return printHelp();
|
|
443
|
+
default:
|
|
444
|
+
console.error(`unknown command: ${command}\n`);
|
|
445
|
+
printHelp();
|
|
446
|
+
process.exit(1);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
main().catch((err) => {
|
|
450
|
+
console.error(String(err instanceof Error ? err.message : err));
|
|
451
|
+
process.exit(1);
|
|
452
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP control API — the seam between the bridge core and whatever drives it.
|
|
3
|
+
*
|
|
4
|
+
* The `serve` daemon holds the live WebSocket connections to tabs, so a one-shot
|
|
5
|
+
* CLI invocation (or any other local process) can't reach them directly. This
|
|
6
|
+
* small loopback HTTP server is how they ask the daemon to do work:
|
|
7
|
+
*
|
|
8
|
+
* GET /health → { ok: true }
|
|
9
|
+
* GET /tabs → TabInfo[]
|
|
10
|
+
* POST /eval → { tab?, code, timeoutMs? } → EvalResult
|
|
11
|
+
*
|
|
12
|
+
* It is deliberately the same shape a future MCP server would call, so swapping
|
|
13
|
+
* the CLI for MCP changes the caller, not this contract.
|
|
14
|
+
*/
|
|
15
|
+
import { createServer } from 'node:http';
|
|
16
|
+
import { z } from 'zod';
|
|
17
|
+
import { BridgeError } from './bridge-server.js';
|
|
18
|
+
import { DEFAULT_CONTROL_PORT, DEFAULT_HOST } from './protocol.js';
|
|
19
|
+
const evalBodySchema = z.object({
|
|
20
|
+
tab: z.string().min(1).optional(),
|
|
21
|
+
code: z.string().min(1),
|
|
22
|
+
timeoutMs: z.number().int().positive().optional(),
|
|
23
|
+
mode: z.enum(['safe', 'unsafe']).optional()
|
|
24
|
+
});
|
|
25
|
+
function send(res, status, body) {
|
|
26
|
+
const payload = JSON.stringify(body);
|
|
27
|
+
res.writeHead(status, {
|
|
28
|
+
'content-type': 'application/json',
|
|
29
|
+
'content-length': Buffer.byteLength(payload)
|
|
30
|
+
});
|
|
31
|
+
res.end(payload);
|
|
32
|
+
}
|
|
33
|
+
function readBody(req) {
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
const chunks = [];
|
|
36
|
+
let size = 0;
|
|
37
|
+
req.on('data', (chunk) => {
|
|
38
|
+
size += chunk.length;
|
|
39
|
+
// Guard against an unbounded body; scripts are small.
|
|
40
|
+
if (size > 5_000_000) {
|
|
41
|
+
reject(new Error('request body too large'));
|
|
42
|
+
req.destroy();
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
chunks.push(chunk);
|
|
46
|
+
});
|
|
47
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
48
|
+
req.on('error', reject);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
/** HTTP status for an operational bridge failure. */
|
|
52
|
+
function statusForBridgeError(code) {
|
|
53
|
+
switch (code) {
|
|
54
|
+
case 'NO_TAB':
|
|
55
|
+
case 'AMBIGUOUS_TAB':
|
|
56
|
+
return 409;
|
|
57
|
+
case 'TIMEOUT':
|
|
58
|
+
return 504;
|
|
59
|
+
case 'DISCONNECTED':
|
|
60
|
+
return 502;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export function createControlServer(bridge, options = {}) {
|
|
64
|
+
const defaultMode = options.defaultMode ?? 'safe';
|
|
65
|
+
return createServer(async (req, res) => {
|
|
66
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
67
|
+
if (req.method === 'GET' && url.pathname === '/health') {
|
|
68
|
+
return send(res, 200, { ok: true });
|
|
69
|
+
}
|
|
70
|
+
if (req.method === 'GET' && url.pathname === '/tabs') {
|
|
71
|
+
return send(res, 200, { tabs: bridge.listTabs() });
|
|
72
|
+
}
|
|
73
|
+
if (req.method === 'POST' && url.pathname === '/eval') {
|
|
74
|
+
let parsed;
|
|
75
|
+
try {
|
|
76
|
+
parsed = evalBodySchema.parse(JSON.parse(await readBody(req)));
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
return send(res, 400, {
|
|
80
|
+
error: `invalid request body: ${String(err)}`
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
const result = await bridge.evalInTab(parsed.code, {
|
|
85
|
+
tab: parsed.tab,
|
|
86
|
+
timeoutMs: parsed.timeoutMs,
|
|
87
|
+
mode: parsed.mode ?? defaultMode
|
|
88
|
+
});
|
|
89
|
+
return send(res, 200, result);
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
if (err instanceof BridgeError) {
|
|
93
|
+
return send(res, statusForBridgeError(err.code), {
|
|
94
|
+
error: err.message,
|
|
95
|
+
code: err.code
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
return send(res, 500, { error: String(err) });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
send(res, 404, { error: `not found: ${req.method} ${url.pathname}` });
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
export function controlBaseUrl(options = {}) {
|
|
105
|
+
const host = options.host ?? DEFAULT_HOST;
|
|
106
|
+
const port = options.port ?? DEFAULT_CONTROL_PORT;
|
|
107
|
+
return `http://${host}:${port}`;
|
|
108
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Programmatic entry point.
|
|
3
|
+
*
|
|
4
|
+
* Import the core to embed the connector in another process — most notably a
|
|
5
|
+
* future MCP server, which would `new BridgeServer()`, register an
|
|
6
|
+
* `eval_etch_script` tool that calls `evalInTab`, and a `list_etch_tabs` tool
|
|
7
|
+
* that calls `listTabs`. No transport code needs to move to get there.
|
|
8
|
+
*/
|
|
9
|
+
export { BridgeServer, BridgeError } from './bridge-server.js';
|
|
10
|
+
export { createControlServer, controlBaseUrl } from './control-server.js';
|
|
11
|
+
export { DEFAULT_WS_PORT, DEFAULT_CONTROL_PORT, DEFAULT_HOST } from './protocol.js';
|
package/dist/manual.js
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
// AUTO-GENERATED from README.md by scripts/build-manual.mjs — do not edit by hand.
|
|
2
|
+
export const MANUAL = "# etch-connector\n\nA tiny local bridge that lets an AI harness — Claude Code, Cowork, or any tool\nthat can run a shell command — **drive a live Etch builder tab** by evaluating\nscripts against the [`etch-public-api`](https://www.npmjs.com/package/@digital-gravy/etch-public-api)\nsurface (`window.etch.*`).\n\nEtch's scriptable API only exists *inside the browser*. There is no server you\ncan call directly. So this connector inverts the usual direction: the browser\n**dials out** to the connector over a WebSocket (the same way Chrome remote\ndebugging works, but in reverse), and the connector relays scripts into the tab\nand streams the result back.\n\n```\nClaude Code / Cowork\n │ shell: etch-connector eval -t homepage \"return ...\"\n ▼\n etch-connector (the `serve` daemon)\n │ ▲ WebSocket (tab dials in via etch.connectTo)\n ▼ │\n Etch builder tab ──► window.etch.*\n```\n\n## Status\n\nThis is an **eval relay**: the harness sends a JavaScript snippet, the page runs\nit as an async function, and the return value + console output come back. It is\nintentionally *not* an MCP server yet, but it is built so that becoming one is a\nthin add — see [Going to MCP](#going-to-mcp-later).\n\nScripts run in one of two modes, enforced by the page:\n\n- **`safe`** (default) — the script sees only `etch` and the standard JS\n built-ins. `window`, `document`, `fetch`, storage, `eval`, … are withheld.\n- **`unsafe`** — the script runs with full access to the page. Opt in\n explicitly.\n\n## Install\n\n```bash\nbun install # or npm install\n```\n\n## Use\n\n### 1. Start the daemon (once)\n\n```bash\nbun run serve\n# bridge ws://127.0.0.1:7331\n# control http://127.0.0.1:7332\n```\n\n### 2. Connect a tab\n\nOpen an Etch builder page and, in the browser console:\n\n```js\netch.connectTo('homepage');\n```\n\n(`connectTo` is part of the public API; it opens the WebSocket back to the\ndaemon and registers this tab under the given name. Pass `{ port: <n> }` if you\nstarted the daemon on a non-default WebSocket port.)\n\n### 3. Drive it\n\n```bash\n# list connected tabs\netch-connector tabs\n\n# run a script (value → stdout, console output → stderr).\n# Default is safe mode: reference the API as `etch.*`.\netch-connector eval -t homepage \"return etch.apiVersion\"\n\n# multi-line / from a file / from stdin\netch-connector eval -t homepage -f ./script.js\necho \"return await etch.blocks.getTree()\" | etch-connector eval -t homepage\n\n# full page access (opt in)\netch-connector eval -t homepage --unsafe \"return document.title\"\n```\n\nThe script body runs as an **async function**, so it can `return` a value and\nuse top-level `await`. In safe mode write scripts against the `etch-public-api`\nsurface via the `etch` binding (`window` is not available). Examples:\n\n```js\nreturn etch.apiVersion;\nreturn await etch.blocks.getTree();\netch.blocks.select('block-id-123');\nconst groups = await etch.fields.listGroupsAsync();\nreturn groups.length;\n```\n\n### Modes\n\n| Mode | In scope | Use for |\n| --------------- | ------------------------------------- | -------------------------------- |\n| `safe` (default)| `etch` + standard JS built-ins | Driving the builder via the API |\n| `unsafe` | everything (`window`, `document`, …) | Debugging, page internals |\n\nPick per call with `--safe` / `--unsafe`, or change the daemon default with\n`serve --unsafe`. A per-call flag overrides the daemon default.\n\n### Output contract (for agents)\n\n`eval` keeps stdout clean so a harness can capture it as the result:\n\n- **stdout** — the script's return value, as pretty JSON.\n- **stderr** — forwarded `console.*` output, prefixed with the level.\n- **exit code** — `0` success · `2` the script threw · `1` operational failure\n (no such tab, ambiguous target, timeout, daemon unreachable).\n\n## CLI\n\n```\netch-connector serve [--ws-port 7331] [--control-port 7332] [--ws-host 127.0.0.1] [--unsafe]\netch-connector tabs [--json] [--control-port 7332]\netch-connector eval [code] [-t|--tab name] [-f|--file path] [--timeout ms] [--safe|--unsafe]\n```\n\nWhen exactly one tab is connected, `-t` is optional. With several tabs you must\nname one.\n\n## HTTP control API\n\nThe daemon also exposes a loopback HTTP API (this is the seam the CLI uses, and\nthe same one a future MCP server would call):\n\n| Method | Path | Body | Returns |\n| ------ | --------- | ------------------------------------ | ------------ |\n| GET | `/health` | — | `{ ok }` |\n| GET | `/tabs` | — | `{ tabs }` |\n| POST | `/eval` | `{ tab?, code, timeoutMs?, mode? }` | `EvalResult` |\n\n## Architecture\n\n- [`src/protocol.ts`](src/protocol.ts) — the wire format, mirrored from the\n Etch-side `bridge.ts`. Keep the two in sync.\n- [`src/bridge-server.ts`](src/bridge-server.ts) — `BridgeServer`, the\n transport-agnostic core: owns the sockets, tracks tabs by name, correlates\n requests, handles timeouts and disconnects. **This is the reusable piece.**\n- [`src/control-server.ts`](src/control-server.ts) — the HTTP control API over\n the core.\n- [`src/cli.ts`](src/cli.ts) — the `serve` / `tabs` / `eval` commands.\n\n## Going to MCP later\n\nNothing in the transport changes. An MCP server is `new BridgeServer()` plus two\ntool definitions over the same core:\n\n```ts\nimport { BridgeServer } from 'etch-connector';\n\nconst bridge = new BridgeServer();\nawait bridge.listen();\n\n// list_etch_tabs -> bridge.listTabs()\n// eval_etch_script -> bridge.evalInTab(code, { tab })\n```\n\nWhen you want first-class per-tool MCP tools (rather than one eval tool), have\nthe Etch tab advertise a tool manifest in its `hello` and register those\ndynamically — keeping a single source of truth in the browser registry.\n\n## Security\n\nTwo layers, neither a substitute for the other:\n\n**Transport.** Everything binds to `127.0.0.1`, so any local process can reach\nthe daemon. Run it only on a trusted machine and do not expose the ports beyond\nloopback.\n\n**Execution.** `safe` mode (the default) runs the script inside a `with`-bound\nProxy that exposes only `etch` plus the standard JS built-ins and throws on\n`window`, `document`, `fetch`, storage, `eval`, `Function`, … This is a\n**guardrail, not a hardened sandbox**: because the script shares the page's JS\nrealm, a *determined* author can still reach globals through prototype /\nconstructor chains (e.g. `({}).constructor.constructor`). Safe mode reliably\nkeeps casual and AI-authored scripts on the `etch` surface and closes the\nobvious escape hatches; it does **not** contain hostile code.\n\n`unsafe` mode runs the script with full, unconstrained access to the page — only\nuse it for trusted debugging.\n\nFor a true boundary against adversarial scripts you would need to harden the\nrealm (SES `lockdown()`), which freezes all intrinsics app-wide and is out of\nscope for this relay. The enforcement lives in the Etch page (`bridge.ts`), not\nthis connector — the connector only carries the requested mode; a string filter\nhere would be theater.\n";
|
package/dist/protocol.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire protocol shared with the Etch-side bridge.
|
|
3
|
+
*
|
|
4
|
+
* The browser end of this protocol lives in the Etch repo at
|
|
5
|
+
* `src/frontend/Builder/src/composables/publicApi/bridge.ts` (`etch.connectTo`).
|
|
6
|
+
* This file is the server's mirror of those message shapes — keep the two in
|
|
7
|
+
* sync. The page is the WebSocket *client*; this connector is the *server*.
|
|
8
|
+
*
|
|
9
|
+
* Direction of travel:
|
|
10
|
+
* server → page : EvalRequest | PingMessage
|
|
11
|
+
* page → server : HelloMessage | LogMessage | ResultMessage | ErrorMessage | PongMessage
|
|
12
|
+
*/
|
|
13
|
+
/** Default WebSocket port the Etch bridge dials (`ws://127.0.0.1:7331`). */
|
|
14
|
+
export const DEFAULT_WS_PORT = 7331;
|
|
15
|
+
/** Default HTTP control-API port the CLI / AI harness talks to. */
|
|
16
|
+
export const DEFAULT_CONTROL_PORT = 7332;
|
|
17
|
+
export const DEFAULT_HOST = '127.0.0.1';
|
|
18
|
+
/** Narrowing parse of an inbound page message; returns null on anything invalid. */
|
|
19
|
+
export function parsePageMessage(raw) {
|
|
20
|
+
let value;
|
|
21
|
+
try {
|
|
22
|
+
value = JSON.parse(raw);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
if (typeof value !== 'object' || value === null)
|
|
28
|
+
return null;
|
|
29
|
+
const type = value.type;
|
|
30
|
+
if (type === 'hello' ||
|
|
31
|
+
type === 'log' ||
|
|
32
|
+
type === 'result' ||
|
|
33
|
+
type === 'error' ||
|
|
34
|
+
type === 'pong') {
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|