@digital-gravy/etch-connector 0.1.5 → 0.2.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/README.md +15 -2
- package/dist/bridge-server.js +2 -1
- package/dist/cdp-client.js +101 -21
- package/dist/cli.js +106 -10
- package/dist/guidance.js +129 -0
- package/dist/index.js +2 -0
- package/dist/protocol.js +42 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -122,12 +122,23 @@ return groups.length;
|
|
|
122
122
|
`eval` keeps stdout clean so a harness can capture it as the result:
|
|
123
123
|
|
|
124
124
|
- **stdout** — the script's return value, as pretty JSON.
|
|
125
|
-
- **stderr** — forwarded `console.*` output, prefixed with the level
|
|
125
|
+
- **stderr** — forwarded `console.*` output, prefixed with the level, plus
|
|
126
|
+
connector notes that carry no such prefix (the runtime banner, warnings).
|
|
126
127
|
- **exit code** — `0` success · `2` the script threw · `1` operational failure
|
|
127
128
|
(no such tab, ambiguous target, timeout, daemon unreachable).
|
|
128
129
|
|
|
129
130
|
## For AI agents
|
|
130
131
|
|
|
132
|
+
`etch-connector tabs` reports, per tab, which Etch runtime it is and which
|
|
133
|
+
surfaces that runtime backs (read off `etch.environment`), then prints the
|
|
134
|
+
authoring rules that follow from it: the dynamic-data model (data sources vs
|
|
135
|
+
loops), the media lookup, the namespaces that throw `NOT_AVAILABLE`, and the
|
|
136
|
+
block types the runtime can construct. Not every runtime backs every surface, so
|
|
137
|
+
read that before writing scripts — and branch on a capability, never on the
|
|
138
|
+
product name. `eval` names the runtime it targets on stderr as a reminder, and
|
|
139
|
+
`tabs --quiet` drops the prose when you are only checking that a tab is still
|
|
140
|
+
connected.
|
|
141
|
+
|
|
131
142
|
The authoritative agent instructions live in **`etch-connector --help`** — read
|
|
132
143
|
the entire output before interacting with Etch. It covers the backends, commands
|
|
133
144
|
and flags, the stdout/stderr/exit-code contract, how to pull the current Etch
|
|
@@ -144,7 +155,7 @@ etch-connector — drive a live Etch tab from the command line
|
|
|
144
155
|
|
|
145
156
|
Usage:
|
|
146
157
|
etch-connector serve [--ws-port 7331] [--control-port 7332] [--ws-host 127.0.0.1]
|
|
147
|
-
etch-connector tabs [--json] [--control-port 7332] [--cdp]
|
|
158
|
+
etch-connector tabs [--json] [--quiet] [--control-port 7332] [--cdp]
|
|
148
159
|
etch-connector eval [code] [-t|--tab name] [-f|--file path] [--timeout ms] [--cdp]
|
|
149
160
|
etch-connector shot [-t name] [-s|--selector css] [--full] [--jpeg] [-o|--out file] [--freeze=false] (CDP)
|
|
150
161
|
etch-connector html <selector> [-t name] (CDP)
|
|
@@ -194,6 +205,8 @@ the same one a future MCP server would call):
|
|
|
194
205
|
requests, handles timeouts and disconnects. **This is the reusable piece.**
|
|
195
206
|
- [`src/control-server.ts`](src/control-server.ts) — the HTTP control API over
|
|
196
207
|
the core.
|
|
208
|
+
- [`src/guidance.ts`](src/guidance.ts) — turns a tab's `etch.environment`
|
|
209
|
+
descriptor into the authoring instructions for that runtime.
|
|
197
210
|
- [`src/cli.ts`](src/cli.ts) — the `serve` / `tabs` / `eval` commands.
|
|
198
211
|
|
|
199
212
|
## Going to MCP later
|
package/dist/bridge-server.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
import { EventEmitter } from 'node:events';
|
|
15
15
|
import { randomUUID } from 'node:crypto';
|
|
16
16
|
import { WebSocketServer } from 'ws';
|
|
17
|
-
import { DEFAULT_HOST, DEFAULT_WS_PORT, parsePageMessage } from './protocol.js';
|
|
17
|
+
import { DEFAULT_HOST, DEFAULT_WS_PORT, parseEnvironment, parsePageMessage } from './protocol.js';
|
|
18
18
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
19
19
|
/**
|
|
20
20
|
* Raised for operational failures (no matching tab, ambiguous target, timeout,
|
|
@@ -208,6 +208,7 @@ export class BridgeServer extends EventEmitter {
|
|
|
208
208
|
hasEtch: hello.hasEtch,
|
|
209
209
|
apiVersion: hello.apiVersion,
|
|
210
210
|
version: hello.version,
|
|
211
|
+
environment: parseEnvironment(hello.environment),
|
|
211
212
|
meta: hello.meta ?? {},
|
|
212
213
|
connectedAt: Date.now()
|
|
213
214
|
};
|
package/dist/cdp-client.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* keep the surface small.
|
|
15
15
|
*/
|
|
16
16
|
import { WebSocket } from 'ws';
|
|
17
|
+
import { z } from 'zod';
|
|
17
18
|
/** A script evaluated over CDP threw. Distinct from operational failures. */
|
|
18
19
|
export class CdpEvalError extends Error {
|
|
19
20
|
/** The full page-side exception text (description + stack), if any. */
|
|
@@ -34,6 +35,62 @@ export class CdpError extends Error {
|
|
|
34
35
|
const DEFAULT_PORT = 9222;
|
|
35
36
|
const DEFAULT_HOST = '127.0.0.1';
|
|
36
37
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
38
|
+
/**
|
|
39
|
+
* Page-side helpers prepended to the inspection expressions. Etch renders its
|
|
40
|
+
* canvas inside a same-origin iframe that `document.querySelector` can't see
|
|
41
|
+
* into, so canvas documents are searched first: `h1` resolves to the page being
|
|
42
|
+
* built, not the builder UI.
|
|
43
|
+
*/
|
|
44
|
+
const PAGE_HELPERS = `
|
|
45
|
+
const __etchDocuments = () => [
|
|
46
|
+
...Array.from(document.querySelectorAll('iframe'), (frame) => frame.contentDocument).filter(Boolean),
|
|
47
|
+
document
|
|
48
|
+
];
|
|
49
|
+
const __etchFind = (selector) => {
|
|
50
|
+
for (const doc of __etchDocuments()) {
|
|
51
|
+
const el = doc.querySelector(selector);
|
|
52
|
+
if (el) return el;
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
};
|
|
56
|
+
// Content outside a frame's viewport isn't painted, even with captureBeyondViewport.
|
|
57
|
+
const __etchClipToFrame = (edges, frame) => ({
|
|
58
|
+
left: Math.max(edges.left, 0),
|
|
59
|
+
top: Math.max(edges.top, 0),
|
|
60
|
+
right: Math.min(edges.right, frame.clientWidth),
|
|
61
|
+
bottom: Math.min(edges.bottom, frame.clientHeight)
|
|
62
|
+
});
|
|
63
|
+
// The canvas is zoomed with a CSS transform, so frame-local pixels are scaled.
|
|
64
|
+
const __etchToParentFrame = (edges, frame) => {
|
|
65
|
+
const frameRect = frame.getBoundingClientRect();
|
|
66
|
+
const scale = frame.offsetWidth ? frameRect.width / frame.offsetWidth : 1;
|
|
67
|
+
return {
|
|
68
|
+
left: frameRect.left + (frame.clientLeft + edges.left) * scale,
|
|
69
|
+
right: frameRect.left + (frame.clientLeft + edges.right) * scale,
|
|
70
|
+
top: frameRect.top + (frame.clientTop + edges.top) * scale,
|
|
71
|
+
bottom: frameRect.top + (frame.clientTop + edges.bottom) * scale
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
const __etchPageRect = (el) => {
|
|
75
|
+
const { left, top, right, bottom } = el.getBoundingClientRect();
|
|
76
|
+
let edges = { left, top, right, bottom };
|
|
77
|
+
let cropped = false;
|
|
78
|
+
for (let frame = el.ownerDocument.defaultView.frameElement; frame; frame = frame.ownerDocument.defaultView.frameElement) {
|
|
79
|
+
const visible = __etchClipToFrame(edges, frame);
|
|
80
|
+
if (visible.right <= visible.left || visible.bottom <= visible.top) return null;
|
|
81
|
+
if (Object.keys(visible).some((side) => visible[side] !== edges[side])) cropped = true;
|
|
82
|
+
edges = __etchToParentFrame(visible, frame);
|
|
83
|
+
}
|
|
84
|
+
return { x: edges.left + scrollX, y: edges.top + scrollY, width: edges.right - edges.left, height: edges.bottom - edges.top, cropped };
|
|
85
|
+
};
|
|
86
|
+
`;
|
|
87
|
+
const PageRectSchema = z.object({
|
|
88
|
+
x: z.number(),
|
|
89
|
+
y: z.number(),
|
|
90
|
+
width: z.number(),
|
|
91
|
+
height: z.number(),
|
|
92
|
+
cropped: z.boolean()
|
|
93
|
+
});
|
|
37
94
|
export class CdpClient {
|
|
38
95
|
host;
|
|
39
96
|
port;
|
|
@@ -187,7 +244,7 @@ export class CdpClient {
|
|
|
187
244
|
}
|
|
188
245
|
/** Rendered `outerHTML` of the first element matching `selector`. */
|
|
189
246
|
async outerHtml(selector) {
|
|
190
|
-
const v = await this.evaluate(`(() => { const el =
|
|
247
|
+
const v = await this.evaluate(`(() => { ${PAGE_HELPERS} const el = __etchFind(${JSON.stringify(selector)}); return el ? el.outerHTML : null; })()`);
|
|
191
248
|
if (v === null)
|
|
192
249
|
throw new CdpError(`no element matches "${selector}"`);
|
|
193
250
|
return v;
|
|
@@ -196,9 +253,10 @@ export class CdpClient {
|
|
|
196
253
|
async computedStyle(selector, props) {
|
|
197
254
|
const keys = props ? JSON.stringify(props) : 'Array.from(cs)';
|
|
198
255
|
const expr = `(() => {
|
|
199
|
-
|
|
256
|
+
${PAGE_HELPERS}
|
|
257
|
+
const el = __etchFind(${JSON.stringify(selector)});
|
|
200
258
|
if (!el) return null;
|
|
201
|
-
const cs = getComputedStyle(el);
|
|
259
|
+
const cs = el.ownerDocument.defaultView.getComputedStyle(el);
|
|
202
260
|
const out = {};
|
|
203
261
|
for (const k of ${keys}) out[k] = cs.getPropertyValue(k);
|
|
204
262
|
return out;
|
|
@@ -217,16 +275,25 @@ export class CdpClient {
|
|
|
217
275
|
try {
|
|
218
276
|
let clip;
|
|
219
277
|
let captureBeyondViewport = false;
|
|
278
|
+
let cropped = false;
|
|
220
279
|
if (options.selector) {
|
|
221
|
-
const rect =
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
280
|
+
const rect = await this.evaluate(`(() => {
|
|
281
|
+
${PAGE_HELPERS}
|
|
282
|
+
const el = __etchFind(${JSON.stringify(options.selector)});
|
|
283
|
+
if (!el) return 'missing';
|
|
284
|
+
const box = el.getBoundingClientRect();
|
|
285
|
+
if (!box.width || !box.height) return 'empty';
|
|
286
|
+
return __etchPageRect(el) ?? 'offscreen';
|
|
287
|
+
})()`);
|
|
288
|
+
if (rect === 'missing')
|
|
228
289
|
throw new CdpError(`no element matches "${options.selector}"`);
|
|
229
|
-
|
|
290
|
+
if (rect === 'empty')
|
|
291
|
+
throw new CdpError(`"${options.selector}" has no rendered size (hidden or empty)`);
|
|
292
|
+
if (rect === 'offscreen')
|
|
293
|
+
throw new CdpError(`"${options.selector}" is outside the visible canvas area — ask the user to scroll it into view in the builder`);
|
|
294
|
+
const { cropped: partlyVisible, ...box } = PageRectSchema.parse(rect);
|
|
295
|
+
clip = { ...box, scale: 1 };
|
|
296
|
+
cropped = partlyVisible;
|
|
230
297
|
captureBeyondViewport = true;
|
|
231
298
|
}
|
|
232
299
|
else if (options.full) {
|
|
@@ -246,7 +313,7 @@ export class CdpClient {
|
|
|
246
313
|
...(clip ? { clip } : {}),
|
|
247
314
|
captureBeyondViewport
|
|
248
315
|
});
|
|
249
|
-
return Buffer.from(data, 'base64');
|
|
316
|
+
return { image: Buffer.from(data, 'base64'), cropped };
|
|
250
317
|
}
|
|
251
318
|
finally {
|
|
252
319
|
if (options.freeze)
|
|
@@ -256,29 +323,42 @@ export class CdpClient {
|
|
|
256
323
|
/** Pause every animation/transition and seek it to a deterministic frame. */
|
|
257
324
|
async freezeAnimations() {
|
|
258
325
|
await this.evaluate(`(() => {
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
326
|
+
${PAGE_HELPERS}
|
|
327
|
+
const documents = __etchDocuments();
|
|
328
|
+
for (const doc of documents) {
|
|
329
|
+
if (doc.getElementById('__etch_freeze__')) continue;
|
|
330
|
+
const s = doc.createElement('style');
|
|
262
331
|
s.id = '__etch_freeze__';
|
|
263
332
|
s.textContent = '*,*::before,*::after{transition:none!important}';
|
|
264
|
-
(
|
|
333
|
+
(doc.head || doc.documentElement).appendChild(s);
|
|
265
334
|
}
|
|
266
|
-
|
|
335
|
+
const frozen = [];
|
|
336
|
+
for (const a of documents.flatMap((doc) => doc.getAnimations())) {
|
|
267
337
|
try {
|
|
338
|
+
frozen.push({ animation: a, playState: a.playState, currentTime: a.currentTime });
|
|
268
339
|
a.pause();
|
|
269
340
|
const end = a.effect && a.effect.getComputedTiming ? a.effect.getComputedTiming().endTime : 0;
|
|
270
341
|
a.currentTime = (typeof end === 'number' && isFinite(end)) ? end : 0;
|
|
271
342
|
} catch (e) { /* leave this one as-is */ }
|
|
272
343
|
}
|
|
344
|
+
window.__etchFrozenAnimations = frozen;
|
|
273
345
|
return true;
|
|
274
346
|
})()`);
|
|
275
347
|
}
|
|
276
|
-
/** Undo {@link freezeAnimations}:
|
|
348
|
+
/** Undo {@link freezeAnimations}: put each animation back where it was and remove the helper style. */
|
|
277
349
|
async restoreAnimations() {
|
|
350
|
+
// A blanket play() would restart finished animations (auto-rewind) and resume ones the page paused itself.
|
|
278
351
|
await this.evaluate(`(() => {
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
for (const
|
|
352
|
+
${PAGE_HELPERS}
|
|
353
|
+
for (const doc of __etchDocuments()) doc.getElementById('__etch_freeze__')?.remove();
|
|
354
|
+
for (const { animation, playState, currentTime } of window.__etchFrozenAnimations ?? []) {
|
|
355
|
+
try {
|
|
356
|
+
animation.currentTime = currentTime;
|
|
357
|
+
if (playState === 'running') animation.play();
|
|
358
|
+
if (playState === 'finished') animation.finish();
|
|
359
|
+
} catch (e) { /* leave this one as-is */ }
|
|
360
|
+
}
|
|
361
|
+
delete window.__etchFrozenAnimations;
|
|
282
362
|
return true;
|
|
283
363
|
})()`);
|
|
284
364
|
}
|
package/dist/cli.js
CHANGED
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
* etch-connector tabs list connected Etch tabs
|
|
8
8
|
* etch-connector eval [code] run a script in a tab; value → stdout
|
|
9
9
|
*
|
|
10
|
-
* `eval` prints forwarded console output to *stderr*
|
|
11
|
-
*
|
|
10
|
+
* `eval` prints forwarded console output to *stderr* — alongside unprefixed
|
|
11
|
+
* connector notes such as the runtime banner — and the script's return value as
|
|
12
|
+
* JSON to *stdout*, so an agent can capture stdout as the clean result.
|
|
12
13
|
* Exit codes: 0 ok · 2 the script threw · 1 everything else (no tab, timeout…).
|
|
13
14
|
*/
|
|
14
15
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
@@ -16,6 +17,7 @@ import { BridgeServer } from './bridge-server.js';
|
|
|
16
17
|
import { createControlServer, controlBaseUrl } from './control-server.js';
|
|
17
18
|
import { CdpClient, CdpError, CdpEvalError } from './cdp-client.js';
|
|
18
19
|
import { DEFAULT_CONTROL_PORT, DEFAULT_HOST, DEFAULT_WS_PORT } from './protocol.js';
|
|
20
|
+
import { backedCapabilities, environmentGuidance, runtimeBanner } from './guidance.js';
|
|
19
21
|
const DEFAULT_CDP_PORT = 9222;
|
|
20
22
|
/**
|
|
21
23
|
* Unsafe mode (scripts get full, unconstrained access to the page) is
|
|
@@ -31,6 +33,7 @@ const BOOLEAN_FLAGS = new Set([
|
|
|
31
33
|
'full',
|
|
32
34
|
'jpeg',
|
|
33
35
|
'json',
|
|
36
|
+
'quiet',
|
|
34
37
|
'unsafe',
|
|
35
38
|
'safe',
|
|
36
39
|
'help',
|
|
@@ -275,6 +278,33 @@ async function tabsViaCdp(cdp, flags) {
|
|
|
275
278
|
for (const t of targets)
|
|
276
279
|
process.stdout.write(`${t.title}\t${t.url}\n`);
|
|
277
280
|
}
|
|
281
|
+
/** The runtime column: what the tab is and which surfaces it actually backs. */
|
|
282
|
+
function describeRuntime(tab) {
|
|
283
|
+
if (!tab.hasEtch)
|
|
284
|
+
return 'no-etch';
|
|
285
|
+
if (!tab.environment)
|
|
286
|
+
return 'etch';
|
|
287
|
+
const backed = backedCapabilities(tab.environment);
|
|
288
|
+
return `${tab.environment.product}(${backed.join(',') || 'none'})`;
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Instructions for the runtimes actually on the wire, to stderr so stdout stays
|
|
292
|
+
* the plain listing. Tabs sharing a runtime share one block — an agent needs the
|
|
293
|
+
* guidance once per runtime, not once per tab.
|
|
294
|
+
*/
|
|
295
|
+
function printRuntimeGuidance(tabs) {
|
|
296
|
+
const byRuntime = new Map();
|
|
297
|
+
for (const tab of tabs.filter((t) => t.hasEtch)) {
|
|
298
|
+
const guidance = environmentGuidance(tab.environment);
|
|
299
|
+
const names = byRuntime.get(guidance) ?? [];
|
|
300
|
+
names.push(tab.name);
|
|
301
|
+
byRuntime.set(guidance, names);
|
|
302
|
+
}
|
|
303
|
+
for (const [guidance, names] of byRuntime) {
|
|
304
|
+
console.error(`\nAGENT — authoring against ${names.join(', ')}:`);
|
|
305
|
+
console.error(guidance);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
278
308
|
async function tabsViaDaemon(flags) {
|
|
279
309
|
const res = await controlFetch(flags, '/tabs');
|
|
280
310
|
const { tabs } = (await res.json());
|
|
@@ -285,8 +315,11 @@ async function tabsViaDaemon(flags) {
|
|
|
285
315
|
return;
|
|
286
316
|
}
|
|
287
317
|
for (const tab of tabs) {
|
|
288
|
-
process.stdout.write(`${tab.name}\t${tab
|
|
318
|
+
process.stdout.write(`${tab.name}\t${describeRuntime(tab)}\t${tab.title}\t${tab.url}\n`);
|
|
289
319
|
}
|
|
320
|
+
// `tabs` doubles as the liveness check, so let a poller opt out of the prose.
|
|
321
|
+
if (!flags['quiet'])
|
|
322
|
+
printRuntimeGuidance(tabs);
|
|
290
323
|
}
|
|
291
324
|
async function runTabs(flags) {
|
|
292
325
|
const cdp = cdpFromFlags(flags);
|
|
@@ -336,17 +369,51 @@ async function evalViaCdp(cdp, flags, code, timeoutMs) {
|
|
|
336
369
|
failCdp(err, cdp.port);
|
|
337
370
|
}
|
|
338
371
|
}
|
|
372
|
+
/**
|
|
373
|
+
* Which tab an eval will land on, mirroring the daemon's own resolution: a named
|
|
374
|
+
* tab (most recently connected wins), or the only one connected. Anything the
|
|
375
|
+
* daemon would reject resolves to nothing — the eval reports that itself.
|
|
376
|
+
*/
|
|
377
|
+
function resolveTargetTab(tabs, name) {
|
|
378
|
+
if (name === undefined)
|
|
379
|
+
return tabs.length === 1 ? tabs[0] : undefined;
|
|
380
|
+
return tabs
|
|
381
|
+
.filter((tab) => tab.name === name)
|
|
382
|
+
.sort((a, b) => b.connectedAt - a.connectedAt)[0];
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Name the runtime this eval targets, on stderr. Best-effort: a listing we
|
|
386
|
+
* cannot fetch or a target we cannot pin down just means no banner — never a
|
|
387
|
+
* failed eval.
|
|
388
|
+
*/
|
|
389
|
+
async function announceTargetRuntime(flags) {
|
|
390
|
+
try {
|
|
391
|
+
const res = await controlFetch(flags, '/tabs');
|
|
392
|
+
if (!res.ok)
|
|
393
|
+
return;
|
|
394
|
+
const { tabs } = (await res.json());
|
|
395
|
+
const target = resolveTargetTab(tabs, str(flags['tab']));
|
|
396
|
+
if (target)
|
|
397
|
+
console.error(runtimeBanner(target.environment));
|
|
398
|
+
}
|
|
399
|
+
catch {
|
|
400
|
+
/* the eval itself reports anything that actually matters */
|
|
401
|
+
}
|
|
402
|
+
}
|
|
339
403
|
async function evalViaDaemon(flags, code, timeoutMs) {
|
|
340
404
|
const mode = resolveEvalMode(flags);
|
|
341
405
|
if (mode === 'unsafe') {
|
|
342
406
|
console.error('⚠ running in unsafe mode — the script has full access to the page');
|
|
343
407
|
}
|
|
408
|
+
// Started before the eval so naming the runtime costs no wall-clock.
|
|
409
|
+
const announced = announceTargetRuntime(flags);
|
|
344
410
|
const body = { tab: str(flags['tab']), code, timeoutMs, mode };
|
|
345
411
|
const res = await controlFetch(flags, '/eval', {
|
|
346
412
|
method: 'POST',
|
|
347
413
|
headers: { 'content-type': 'application/json' },
|
|
348
414
|
body: JSON.stringify(body)
|
|
349
415
|
});
|
|
416
|
+
await announced;
|
|
350
417
|
if (!res.ok) {
|
|
351
418
|
const err = (await res.json().catch(() => ({})));
|
|
352
419
|
console.error(`eval failed: ${err.error ?? res.statusText}`);
|
|
@@ -384,14 +451,16 @@ async function runShot(flags) {
|
|
|
384
451
|
const format = flags['jpeg'] ? 'jpeg' : 'png';
|
|
385
452
|
const freeze = bool(flags['freeze'], true); // default on (animations → deterministic)
|
|
386
453
|
const out = str(flags['out']) ?? `etch-shot.${format === 'jpeg' ? 'jpg' : 'png'}`;
|
|
387
|
-
const
|
|
388
|
-
writeFileSync(out,
|
|
454
|
+
const { image, cropped } = await withRequiredCdp(flags, (c) => c.screenshot({ selector, full, format, freeze }));
|
|
455
|
+
writeFileSync(out, image);
|
|
389
456
|
const scope = selector
|
|
390
457
|
? ` — clipped to ${selector}`
|
|
391
458
|
: full
|
|
392
459
|
? ' — full page'
|
|
393
460
|
: ' — viewport';
|
|
394
|
-
console.error(`wrote ${out} (${
|
|
461
|
+
console.error(`wrote ${out} (${image.length} bytes)${scope}${freeze ? ' (frozen)' : ''}`);
|
|
462
|
+
if (cropped)
|
|
463
|
+
console.error(`warning: ${selector} extends past the visible canvas area, so the shot only shows the visible part — ask the user to scroll it fully into view`);
|
|
395
464
|
}
|
|
396
465
|
async function runHtml(positional, flags) {
|
|
397
466
|
const selector = requireSelector('html', positional, flags);
|
|
@@ -419,7 +488,7 @@ Or install globally for the bare \`etch-connector\` command:
|
|
|
419
488
|
|
|
420
489
|
Usage:
|
|
421
490
|
etch-connector serve [--ws-port ${DEFAULT_WS_PORT}] [--control-port ${DEFAULT_CONTROL_PORT}] [--ws-host ${DEFAULT_HOST}]
|
|
422
|
-
etch-connector tabs [--json] [--control-port ${DEFAULT_CONTROL_PORT}] [--cdp]
|
|
491
|
+
etch-connector tabs [--json] [--quiet] [--control-port ${DEFAULT_CONTROL_PORT}] [--cdp]
|
|
423
492
|
etch-connector eval [code] [-t|--tab name] [-f|--file path] [--timeout ms] [--cdp]
|
|
424
493
|
etch-connector shot [-t name] [-s|--selector css] [--full] [--jpeg] [-o|--out file] [--freeze=false] (CDP)
|
|
425
494
|
etch-connector html <selector> [-t name] (CDP)
|
|
@@ -437,6 +506,29 @@ AGENT: before running anything, ask the user how they want to connect — bridge
|
|
|
437
506
|
(API only) or cdp (DevTools privilege: full page access). cdp grants more reach;
|
|
438
507
|
don't assume — let the user choose.
|
|
439
508
|
|
|
509
|
+
AGENT: EVERY change goes through the public etch.* API — no exceptions, in
|
|
510
|
+
either backend, for anything, inline styles included. NEVER touch the canvas
|
|
511
|
+
directly: no document/DOM manipulation, no injected <style> tags, no setting
|
|
512
|
+
attributes or styles on canvas elements or iframes. Styling goes through
|
|
513
|
+
Etch's styling system by default: etch.styles.* for selectors/classes,
|
|
514
|
+
etch.blocks.addClass() to apply them, etch.stylesheets.* for global CSS.
|
|
515
|
+
When a value genuinely belongs inline (e.g. a per-instance custom property
|
|
516
|
+
bound to a prop, style="--radius: {props.radius};"), it is still authored
|
|
517
|
+
through the API — as the block's style attribute via etch.blocks.* — so it
|
|
518
|
+
lands in the saved document, not just the rendered page. cdp's page access is
|
|
519
|
+
for inspecting (shot, html, computed) — never for writing. Those three already
|
|
520
|
+
look inside the canvas iframe, so inspecting a canvas element never needs a
|
|
521
|
+
hand-written eval.
|
|
522
|
+
|
|
523
|
+
AGENT: \`etch-connector tabs\` prints, per connected tab, which runtime it is and
|
|
524
|
+
which surfaces that runtime backs (from \`etch.environment\`), followed by the
|
|
525
|
+
authoring rules for it — the dynamic-data model, the media lookup, the
|
|
526
|
+
namespaces that throw, and the block types it can construct. Read that before
|
|
527
|
+
writing scripts: not every runtime backs every surface, so the same script does
|
|
528
|
+
not work everywhere. Branch on a capability, never on the product name.
|
|
529
|
+
(\`eval\` names the runtime it targets on stderr as a reminder; \`tabs --quiet\`
|
|
530
|
+
drops the prose when you are only checking a tab is still connected.)
|
|
531
|
+
|
|
440
532
|
Connecting a bridge tab (AGENT: walk the user through activating it — the AI
|
|
441
533
|
button is the primary path; don't just wait for a tab to appear):
|
|
442
534
|
1. Tell them to open Etch Settings and enable the AI Connector.
|
|
@@ -462,7 +554,8 @@ Examples:
|
|
|
462
554
|
|
|
463
555
|
The eval body runs as an async function, so it may 'return' a value and use
|
|
464
556
|
top-level 'await'. In bridge mode reference the API as "etch.*" ("window" is
|
|
465
|
-
withheld); in cdp mode the full page is
|
|
557
|
+
withheld); in cdp mode the full page is readable, but changes still go
|
|
558
|
+
through etch.* only.
|
|
466
559
|
|
|
467
560
|
AI presence (AGENT: drive the etch.ai overlay so the human watching the tab
|
|
468
561
|
sees an agent is active — this connector is exactly what etch.ai is built for.
|
|
@@ -539,10 +632,13 @@ render what you author):
|
|
|
539
632
|
etch.blocks.update(instanceId, { attributes: { image: "10" } })
|
|
540
633
|
• updateAsync({ blocks }) requires styles:[] on every element block
|
|
541
634
|
(validation differs from blocks.create(), which omits styles entirely).
|
|
542
|
-
• No etch.media API.
|
|
635
|
+
• No etch.media API. Where the runtime backs the WordPress media library
|
|
636
|
+
(capability \`wpMedia\` — see \`tabs\`), resolve a URL → media ID via the WP
|
|
637
|
+
REST API:
|
|
543
638
|
curl 'http://<site>/?rest_route=/wp/v2/media&per_page=100&_fields=id,source_url'
|
|
544
639
|
(rest_route form works on plain permalinks; /wp-json/wp/v2/media on pretty
|
|
545
|
-
ones.)
|
|
640
|
+
ones.) Without it the ids are the product's own asset store and there is
|
|
641
|
+
nothing to look a URL up against.
|
|
546
642
|
|
|
547
643
|
See AGENTS.md for project conventions.`;
|
|
548
644
|
function printHelp() {
|
package/dist/guidance.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turns a tab's `etch.environment` descriptor into the instructions for
|
|
3
|
+
* authoring against *that* runtime.
|
|
4
|
+
*
|
|
5
|
+
* The two dynamic-data models are mutually exclusive and are authored
|
|
6
|
+
* differently, the media lookup depends on whether WordPress is underneath, and
|
|
7
|
+
* several namespaces are declared everywhere but backed only somewhere — so a
|
|
8
|
+
* script written for one product silently produces nothing on the other. This
|
|
9
|
+
* is what turns the descriptor from something the connector relays into
|
|
10
|
+
* something it acts on.
|
|
11
|
+
*
|
|
12
|
+
* Every branch keys off {@link hasCapability}, never `product`: capabilities
|
|
13
|
+
* move between products, and a product check rots the day one does.
|
|
14
|
+
*/
|
|
15
|
+
import { hasCapability } from './protocol.js';
|
|
16
|
+
/** Namespaces that are declared on every runtime but backed only on some. */
|
|
17
|
+
const GATED_NAMESPACES = [
|
|
18
|
+
{ capability: 'loops', namespace: 'etch.loops.*' },
|
|
19
|
+
{ capability: 'fields', namespace: 'etch.fields.*' },
|
|
20
|
+
{ capability: 'templates', namespace: "etch.navigation.goTo('templates')" }
|
|
21
|
+
];
|
|
22
|
+
const UNKNOWN_RUNTIME = [
|
|
23
|
+
'Runtime: not reported — this Etch predates etch.environment, so what follows',
|
|
24
|
+
' is an ASSUMPTION, not a reading of the runtime: WordPress rules (loops,',
|
|
25
|
+
' fields, templates, WP media), and every block type may exist. Confirm with',
|
|
26
|
+
' the user before relying on it — a non-WordPress runtime throws on most of',
|
|
27
|
+
' that.'
|
|
28
|
+
];
|
|
29
|
+
function dynamicDataGuidance(environment) {
|
|
30
|
+
if (hasCapability(environment, 'dataSources')) {
|
|
31
|
+
return [
|
|
32
|
+
'Dynamic data — data sources. Named project sources (json, api, js) are',
|
|
33
|
+
" reached through a data('key') expression. An etch/loop block carries",
|
|
34
|
+
' ONLY a required `target`, and that target is any dynamic expression',
|
|
35
|
+
" yielding a list: data('products'), a path into one",
|
|
36
|
+
" (data('products').items), nested data from an enclosing loop",
|
|
37
|
+
' (item.tags), or a generated list (range(1, 10)). There is no',
|
|
38
|
+
' loopId/loopParams here.'
|
|
39
|
+
];
|
|
40
|
+
}
|
|
41
|
+
if (hasCapability(environment, 'loops')) {
|
|
42
|
+
return [
|
|
43
|
+
'Dynamic data — loops. etch.loops.* is a registry of named loop',
|
|
44
|
+
' definitions. An etch/loop block binds one by `loopId`, passes',
|
|
45
|
+
' $-prefixed `loopParams`, and `target` is an optional sub-path within',
|
|
46
|
+
" it. There are no data sources here — a data('key') expression",
|
|
47
|
+
' resolves to nothing.'
|
|
48
|
+
];
|
|
49
|
+
}
|
|
50
|
+
return [
|
|
51
|
+
'Dynamic data: unavailable. This runtime backs neither loops nor data',
|
|
52
|
+
' sources — do not author etch/loop blocks against it.'
|
|
53
|
+
];
|
|
54
|
+
}
|
|
55
|
+
function mediaGuidance(environment) {
|
|
56
|
+
const binding = ' Bind an etch/dynamic-image by a numeric `mediaId` attribute, never a URL.';
|
|
57
|
+
if (hasCapability(environment, 'wpMedia')) {
|
|
58
|
+
return [
|
|
59
|
+
'Media — the WordPress library. Media ids resolve against /wp/v2/media, so',
|
|
60
|
+
' a URL can be looked up:',
|
|
61
|
+
" curl '<site>/?rest_route=/wp/v2/media&per_page=100&_fields=id,source_url'",
|
|
62
|
+
binding
|
|
63
|
+
];
|
|
64
|
+
}
|
|
65
|
+
return [
|
|
66
|
+
"Media — this runtime's own asset store, NOT the WordPress library:",
|
|
67
|
+
' /wp/v2/media does not apply and there is nothing to look a URL up against.',
|
|
68
|
+
binding
|
|
69
|
+
];
|
|
70
|
+
}
|
|
71
|
+
function unavailableGuidance(environment) {
|
|
72
|
+
const throwing = GATED_NAMESPACES.filter((gated) => !hasCapability(environment, gated.capability)).map((gated) => gated.namespace);
|
|
73
|
+
if (throwing.length === 0)
|
|
74
|
+
return [];
|
|
75
|
+
return [
|
|
76
|
+
'Throws EtchApiError NOT_AVAILABLE here — do not call:',
|
|
77
|
+
` ${throwing.join(', ')}`
|
|
78
|
+
];
|
|
79
|
+
}
|
|
80
|
+
function blockTypeGuidance(environment) {
|
|
81
|
+
if (environment.blockTypes.length === 0)
|
|
82
|
+
return [];
|
|
83
|
+
return [
|
|
84
|
+
'Block types this runtime can construct — author nothing outside this list:',
|
|
85
|
+
` ${environment.blockTypes.join(', ')}`
|
|
86
|
+
];
|
|
87
|
+
}
|
|
88
|
+
/** Which capabilities a runtime actually backs, for a one-line summary. */
|
|
89
|
+
export function backedCapabilities(environment) {
|
|
90
|
+
return Object.entries(environment.capabilities)
|
|
91
|
+
.filter(([, backed]) => backed === true)
|
|
92
|
+
.map(([capability]) => capability)
|
|
93
|
+
.sort();
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* One stderr line naming the runtime a script is about to run against.
|
|
97
|
+
*
|
|
98
|
+
* `tabs` is where the full instructions live, but `-t` is optional with a single
|
|
99
|
+
* tab connected — so an agent can go straight to `eval` and never see them. This
|
|
100
|
+
* is the line that keeps that agent from silently authoring for the wrong
|
|
101
|
+
* product, and points at where the rules are.
|
|
102
|
+
*/
|
|
103
|
+
export function runtimeBanner(environment) {
|
|
104
|
+
if (!environment) {
|
|
105
|
+
return '[etch] runtime reports no capabilities — assuming WordPress; confirm before relying on it';
|
|
106
|
+
}
|
|
107
|
+
const backed = backedCapabilities(environment);
|
|
108
|
+
return (`[${environment.product}] backs ${backed.join(', ') || 'no optional surface'}` +
|
|
109
|
+
" — run 'etch-connector tabs' for the authoring rules");
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Agent-facing instructions for one runtime. An absent descriptor is a runtime
|
|
113
|
+
* too old to report one, which is always a WordPress build.
|
|
114
|
+
*/
|
|
115
|
+
export function environmentGuidance(environment) {
|
|
116
|
+
if (!environment)
|
|
117
|
+
return UNKNOWN_RUNTIME.join('\n');
|
|
118
|
+
const backed = backedCapabilities(environment);
|
|
119
|
+
const sections = [
|
|
120
|
+
[
|
|
121
|
+
`Runtime: ${environment.product} — backs ${backed.length > 0 ? backed.join(', ') : 'no optional surface'}.`
|
|
122
|
+
],
|
|
123
|
+
dynamicDataGuidance(environment),
|
|
124
|
+
mediaGuidance(environment),
|
|
125
|
+
unavailableGuidance(environment),
|
|
126
|
+
blockTypeGuidance(environment)
|
|
127
|
+
].filter((section) => section.length > 0);
|
|
128
|
+
return sections.map((section) => section.join('\n')).join('\n');
|
|
129
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -9,3 +9,5 @@
|
|
|
9
9
|
export { BridgeServer, BridgeError } from './bridge-server.js';
|
|
10
10
|
export { createControlServer, controlBaseUrl } from './control-server.js';
|
|
11
11
|
export { DEFAULT_WS_PORT, DEFAULT_CONTROL_PORT, DEFAULT_HOST } from './protocol.js';
|
|
12
|
+
export { hasCapability, parseEnvironment } from './protocol.js';
|
|
13
|
+
export { backedCapabilities, environmentGuidance, runtimeBanner } from './guidance.js';
|
package/dist/protocol.js
CHANGED
|
@@ -10,6 +10,48 @@
|
|
|
10
10
|
* server → page : EvalRequest | PingMessage
|
|
11
11
|
* page → server : HelloMessage | LogMessage | ResultMessage | ErrorMessage | PongMessage
|
|
12
12
|
*/
|
|
13
|
+
/** Every optional surface a runtime may or may not back. */
|
|
14
|
+
const CAPABILITY_NAMES = [
|
|
15
|
+
'loops',
|
|
16
|
+
'dataSources',
|
|
17
|
+
'fields',
|
|
18
|
+
'templates',
|
|
19
|
+
'wpMedia'
|
|
20
|
+
];
|
|
21
|
+
/** Whether a runtime backs a capability. Absent or unknown counts as no. */
|
|
22
|
+
export function hasCapability(environment, capability) {
|
|
23
|
+
return environment?.capabilities[capability] === true;
|
|
24
|
+
}
|
|
25
|
+
function isRecord(value) {
|
|
26
|
+
return typeof value === 'object' && value !== null;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Narrow an `environment` off the wire. The page is untrusted input, so a
|
|
30
|
+
* malformed descriptor becomes `undefined` (treated as "runtime too old to
|
|
31
|
+
* report one") rather than propagating a broken shape.
|
|
32
|
+
*/
|
|
33
|
+
export function parseEnvironment(value) {
|
|
34
|
+
if (!isRecord(value))
|
|
35
|
+
return undefined;
|
|
36
|
+
const { product, capabilities, blockTypes } = value;
|
|
37
|
+
if (typeof product !== 'string')
|
|
38
|
+
return undefined;
|
|
39
|
+
if (!isRecord(capabilities))
|
|
40
|
+
return undefined;
|
|
41
|
+
if (!Array.isArray(blockTypes))
|
|
42
|
+
return undefined;
|
|
43
|
+
const known = {};
|
|
44
|
+
for (const name of CAPABILITY_NAMES) {
|
|
45
|
+
const flag = capabilities[name];
|
|
46
|
+
if (typeof flag === 'boolean')
|
|
47
|
+
known[name] = flag;
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
product,
|
|
51
|
+
capabilities: known,
|
|
52
|
+
blockTypes: blockTypes.filter((type) => typeof type === 'string')
|
|
53
|
+
};
|
|
54
|
+
}
|
|
13
55
|
/** Default WebSocket port the Etch bridge dials (`ws://127.0.0.1:7331`). */
|
|
14
56
|
export const DEFAULT_WS_PORT = 7331;
|
|
15
57
|
/** Default HTTP control-API port the CLI / AI harness talks to. */
|