@digital-gravy/etch-connector 0.3.0 → 0.5.0
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 +30 -1
- package/dist/assets.js +159 -0
- package/dist/cli.js +132 -36
- package/dist/control-server.js +46 -28
- package/dist/guidance.js +20 -0
- package/dist/protocol.js +2 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -157,6 +157,8 @@ Usage:
|
|
|
157
157
|
etch-connector serve [--ws-port 7331] [--control-port 7332] [--ws-host 127.0.0.1]
|
|
158
158
|
etch-connector tabs [--json] [--quiet] [--control-port 7332] [--cdp]
|
|
159
159
|
etch-connector eval [code] [-t|--tab name] [-f|--file path] [--timeout ms] [--cdp]
|
|
160
|
+
etch-connector upload <file…> [-t name] [--alt text] [--collection id] [--compress webp:80[:maxWidth]] [--cdp]
|
|
161
|
+
etch-connector download <id…> [-t name] [-o|--out dir|file] [--force] [--cdp]
|
|
160
162
|
etch-connector shot [-t name] [-s|--selector css] [--full] [--jpeg] [-o|--out file] [--freeze=false] (CDP)
|
|
161
163
|
etch-connector html <selector> [-t name] (CDP)
|
|
162
164
|
etch-connector computed <selector> [-t name] [--props a,b,c] (CDP)
|
|
@@ -185,6 +187,31 @@ Examples:
|
|
|
185
187
|
When exactly one tab is connected, `-t` is optional. With several tabs you must
|
|
186
188
|
name one.
|
|
187
189
|
|
|
190
|
+
### Assets
|
|
191
|
+
|
|
192
|
+
On a runtime that backs the `assets` capability (Etch Studio), `upload` and
|
|
193
|
+
`download` move files between disk and `etch.assets`. The bridge carries JSON
|
|
194
|
+
only, so the connector base64-encodes the bytes; you work with paths.
|
|
195
|
+
|
|
196
|
+
```sh
|
|
197
|
+
# Store a photo as WebP at quality 80, capped at 2400px wide; prints the asset
|
|
198
|
+
etch-connector upload ./hero.jpg --alt "Team on the roof" --compress webp:80:2400
|
|
199
|
+
|
|
200
|
+
# Write an asset's bytes to a file (or into a directory, under its own name)
|
|
201
|
+
etch-connector download 12 --out /tmp/hero.webp
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
`upload` prints the stored asset (its `id` is what an `etch/dynamic-image`
|
|
205
|
+
`mediaId` takes) and `download` prints `{ id, path, bytes }`. A file over Etch's
|
|
206
|
+
24.9 MiB limit fails locally, before anything is sent; with `--compress` the
|
|
207
|
+
limit is on the stored result, so a source up to 60 MiB may go. `--alt` takes
|
|
208
|
+
one file per command. `download` refuses to overwrite an existing file, or to
|
|
209
|
+
write a name starting with `.`, unless `--force` is given (the name comes from
|
|
210
|
+
the tab). Both default to a 4-minute timeout, since compressing a large photo in
|
|
211
|
+
the tab is slow; `--timeout` overrides it. A timed-out upload may still land, so
|
|
212
|
+
check `etch.assets.listAsync()` before retrying. A runtime without
|
|
213
|
+
`etch.assets` fails the script with "this runtime has no etch.assets" (exit 2).
|
|
214
|
+
|
|
188
215
|
## HTTP control API
|
|
189
216
|
|
|
190
217
|
The daemon also exposes a loopback HTTP API (this is the seam the CLI uses, and
|
|
@@ -222,7 +249,9 @@ Two layers, neither a substitute for the other:
|
|
|
222
249
|
|
|
223
250
|
**Transport.** Everything binds to `127.0.0.1`, so any local process can reach
|
|
224
251
|
the daemon. Run it only on a trusted machine and do not expose the ports beyond
|
|
225
|
-
loopback.
|
|
252
|
+
loopback. The control API refuses any request carrying an `Origin` header —
|
|
253
|
+
browsers always send one, local tools do not — and `/eval` only takes
|
|
254
|
+
`application/json`, so a web page you visit cannot drive a tab through it.
|
|
226
255
|
|
|
227
256
|
**Execution.** Bridge mode runs the script inside a `with`-bound Proxy that
|
|
228
257
|
exposes only `etch` plus the standard JS built-ins and throws on `window`,
|
package/dist/assets.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `upload` / `download`: files on disk ↔ `etch.assets` in the tab. The bridge
|
|
3
|
+
* carries JSON only, so bytes cross as base64 and this module does the
|
|
4
|
+
* encoding — an agent works with paths, never with base64.
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { basename, extname, join, sep } from 'node:path';
|
|
8
|
+
/** Etch Studio's per-file cap (`MAX_ASSET_FILE_BYTES` there), on what it stores. */
|
|
9
|
+
export const MAX_ASSET_FILE_BYTES = Math.floor(24.9 * 1024 * 1024);
|
|
10
|
+
/**
|
|
11
|
+
* A file to compress may start larger, since Studio checks what it stores. Its
|
|
12
|
+
* base64 still has to fit the bridge's WebSocket frame (100 MiB).
|
|
13
|
+
*/
|
|
14
|
+
// ponytail: fixed cap; raise it with the ws maxPayload if photos outgrow it.
|
|
15
|
+
export const MAX_COMPRESS_SOURCE_BYTES = 60 * 1024 * 1024;
|
|
16
|
+
const FORMATS = ['webp', 'avif', 'jpeg', 'png'];
|
|
17
|
+
function isFormat(value) {
|
|
18
|
+
return FORMATS.some((format) => format === value);
|
|
19
|
+
}
|
|
20
|
+
// NaN would cross the bridge as null and reach the encoder unchecked.
|
|
21
|
+
function wholeNumber(value, [min, max], expected) {
|
|
22
|
+
const n = Number(value);
|
|
23
|
+
if (Number.isInteger(n) && n >= min && n <= max)
|
|
24
|
+
return n;
|
|
25
|
+
throw new Error(`--compress: expected ${expected} (got "${value}")`);
|
|
26
|
+
}
|
|
27
|
+
/** `webp`, `webp:80` or `webp:80:2400` → format, quality, max width. */
|
|
28
|
+
export function parseCompress(spec) {
|
|
29
|
+
const [format = '', quality, maxWidth] = spec.split(':');
|
|
30
|
+
if (!isFormat(format)) {
|
|
31
|
+
throw new Error(`--compress: format must be one of ${FORMATS.join(', ')} (got "${format}")`);
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
format,
|
|
35
|
+
...(quality && {
|
|
36
|
+
quality: wholeNumber(quality, [0, 100], 'a quality as a whole number 0–100')
|
|
37
|
+
}),
|
|
38
|
+
...(maxWidth && {
|
|
39
|
+
resize: {
|
|
40
|
+
type: 'axis',
|
|
41
|
+
axis: 'width',
|
|
42
|
+
pixels: wholeNumber(maxWidth, [1, Infinity], 'a max width in whole pixels')
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/** A flag's value; a bare `--flag` that needs one is an error, not a no-op. */
|
|
48
|
+
export function flagValue(name, value) {
|
|
49
|
+
if (value === true)
|
|
50
|
+
throw new Error(`--${name} needs a value`);
|
|
51
|
+
return typeof value === 'string' ? value : undefined;
|
|
52
|
+
}
|
|
53
|
+
function collectionIds(collection) {
|
|
54
|
+
const id = Number(collection);
|
|
55
|
+
if (!Number.isInteger(id)) {
|
|
56
|
+
throw new Error(`--collection: "${collection}" is not a collection id`);
|
|
57
|
+
}
|
|
58
|
+
return [id];
|
|
59
|
+
}
|
|
60
|
+
export function uploadOptions(flags, fileCount) {
|
|
61
|
+
const alt = flagValue('alt', flags.alt);
|
|
62
|
+
const collection = flagValue('collection', flags.collection);
|
|
63
|
+
const compress = flagValue('compress', flags.compress);
|
|
64
|
+
if (alt !== undefined && fileCount > 1) {
|
|
65
|
+
throw new Error('--alt describes one image: upload the files one at a time to give each its own');
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
...(alt !== undefined && { alt }),
|
|
69
|
+
...(collection !== undefined && {
|
|
70
|
+
collectionIds: collectionIds(collection)
|
|
71
|
+
}),
|
|
72
|
+
...(compress !== undefined && { compress: parseCompress(compress) })
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/** Cheap: a stat, so every file can be checked before the first upload. */
|
|
76
|
+
export function assertUploadable(path, compressing) {
|
|
77
|
+
const size = statSync(path).size;
|
|
78
|
+
const limit = compressing
|
|
79
|
+
? MAX_COMPRESS_SOURCE_BYTES
|
|
80
|
+
: MAX_ASSET_FILE_BYTES;
|
|
81
|
+
if (size > limit) {
|
|
82
|
+
const mib = (bytes) => (bytes / 1024 / 1024).toFixed(1);
|
|
83
|
+
const what = compressing ? 'a file to compress' : 'a file';
|
|
84
|
+
throw new Error(`${path} is ${mib(size)} MiB, over the ${mib(limit)} MiB limit for ${what}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/** Reads the file; check it with `assertUploadable` first. */
|
|
88
|
+
export function uploadInput(path, options) {
|
|
89
|
+
return {
|
|
90
|
+
data: readFileSync(path).toString('base64'),
|
|
91
|
+
fileName: basename(path),
|
|
92
|
+
...options
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
const REQUIRE_ASSETS = "if (!etch.assets) throw new Error('this runtime has no etch.assets');";
|
|
96
|
+
export function uploadScript(input) {
|
|
97
|
+
return `${REQUIRE_ASSETS} return etch.assets.uploadAsync(${JSON.stringify(input)});`;
|
|
98
|
+
}
|
|
99
|
+
export function downloadScript(id) {
|
|
100
|
+
return `${REQUIRE_ASSETS} return etch.assets.downloadAsync(${JSON.stringify(id)});`;
|
|
101
|
+
}
|
|
102
|
+
function isAssetFile(value) {
|
|
103
|
+
return (typeof value === 'object' &&
|
|
104
|
+
value !== null &&
|
|
105
|
+
'fileName' in value &&
|
|
106
|
+
typeof value.fileName === 'string' &&
|
|
107
|
+
'data' in value &&
|
|
108
|
+
typeof value.data === 'string');
|
|
109
|
+
}
|
|
110
|
+
export function isDirectory(path) {
|
|
111
|
+
return existsSync(path) && statSync(path).isDirectory();
|
|
112
|
+
}
|
|
113
|
+
// `sep` too: on Windows a folder is typed `out\`.
|
|
114
|
+
function namesDirectory(out) {
|
|
115
|
+
return out.endsWith('/') || out.endsWith(sep) || isDirectory(out);
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Where `out` puts the asset, creating a named directory. The tab picks the
|
|
119
|
+
* name, and whatever connected may not be Etch.
|
|
120
|
+
*/
|
|
121
|
+
function downloadPath(id, out, file) {
|
|
122
|
+
const name = basename(file.fileName);
|
|
123
|
+
if (!name || name.startsWith('.')) {
|
|
124
|
+
throw new Error(`asset ${id}: refusing to write "${file.fileName}", which is not a plain file name`);
|
|
125
|
+
}
|
|
126
|
+
if (namesDirectory(out)) {
|
|
127
|
+
mkdirSync(out, { recursive: true });
|
|
128
|
+
return join(out, name);
|
|
129
|
+
}
|
|
130
|
+
if (!existsSync(out) && !extname(out)) {
|
|
131
|
+
throw new Error(`--out ${out}: no such directory, and no extension to name a file by`);
|
|
132
|
+
}
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
function writeFile(path, bytes, { force }) {
|
|
136
|
+
try {
|
|
137
|
+
writeFileSync(path, bytes, { flag: force ? 'w' : 'wx' });
|
|
138
|
+
}
|
|
139
|
+
catch (err) {
|
|
140
|
+
if (err.code === 'EEXIST') {
|
|
141
|
+
throw new Error(`${path} already exists; pass --force to overwrite it`);
|
|
142
|
+
}
|
|
143
|
+
throw err;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Write what `downloadAsync` returned. `out` is a directory (the asset's own
|
|
148
|
+
* name goes inside it) or, for a single id, the file path itself. An existing
|
|
149
|
+
* file is left alone unless `force` is set.
|
|
150
|
+
*/
|
|
151
|
+
export function saveDownload(id, value, out, { force = false } = {}) {
|
|
152
|
+
if (!isAssetFile(value)) {
|
|
153
|
+
throw new Error(`asset ${id}: the tab returned no file`);
|
|
154
|
+
}
|
|
155
|
+
const path = downloadPath(id, out, value);
|
|
156
|
+
const bytes = Buffer.from(value.data, 'base64');
|
|
157
|
+
writeFile(path, bytes, { force });
|
|
158
|
+
return { id, path, bytes: bytes.length };
|
|
159
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
3
|
* etch-connector CLI — the thing an AI harness (Claude Code, Cowork, …) shells
|
|
4
|
-
* out to.
|
|
4
|
+
* out to. The core commands:
|
|
5
5
|
*
|
|
6
6
|
* etch-connector serve start the bridge daemon (run this once)
|
|
7
7
|
* etch-connector tabs list connected Etch tabs
|
|
8
8
|
* etch-connector eval [code] run a script in a tab; value → stdout
|
|
9
|
+
* etch-connector upload <file…> store files through etch.assets
|
|
10
|
+
* etch-connector download <id…> write assets' bytes to disk
|
|
9
11
|
*
|
|
10
12
|
* `eval` prints forwarded console output to *stderr* — alongside unprefixed
|
|
11
13
|
* connector notes such as the runtime banner — and the script's return value as
|
|
@@ -13,6 +15,7 @@
|
|
|
13
15
|
* Exit codes: 0 ok · 2 the script threw · 1 everything else (no tab, timeout…).
|
|
14
16
|
*/
|
|
15
17
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
18
|
+
import { assertUploadable, downloadScript, flagValue, isDirectory, saveDownload, uploadInput, uploadOptions, uploadScript } from './assets.js';
|
|
16
19
|
import { BridgeServer } from './bridge-server.js';
|
|
17
20
|
import { createControlServer, controlBaseUrl } from './control-server.js';
|
|
18
21
|
import { CdpClient, CdpError, CdpEvalError } from './cdp-client.js';
|
|
@@ -229,13 +232,25 @@ function cdpLaunchHint(port) {
|
|
|
229
232
|
'Firefox is not supported — CDP requires a Chromium-family browser.'
|
|
230
233
|
].join('\n');
|
|
231
234
|
}
|
|
235
|
+
/**
|
|
236
|
+
* A failure already reported on stderr, carrying the exit code `main` ends
|
|
237
|
+
* with. Thrown rather than exiting on the spot, so a multi-file command can
|
|
238
|
+
* still print what it finished first.
|
|
239
|
+
*/
|
|
240
|
+
class CliExit extends Error {
|
|
241
|
+
code;
|
|
242
|
+
constructor(code) {
|
|
243
|
+
super(`exit ${code}`);
|
|
244
|
+
this.code = code;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
232
247
|
/** Map a thrown CDP error to the right exit: 2 for script throws, 1 otherwise. */
|
|
233
248
|
function failCdp(err, port = DEFAULT_CDP_PORT) {
|
|
234
249
|
if (err instanceof CdpEvalError) {
|
|
235
250
|
console.error(`script error: ${err.message}`);
|
|
236
251
|
if (err.details)
|
|
237
252
|
console.error(err.details);
|
|
238
|
-
|
|
253
|
+
throw new CliExit(2);
|
|
239
254
|
}
|
|
240
255
|
console.error(err instanceof CdpError ? err.message : String(err));
|
|
241
256
|
// The connection-failure message comes from CdpClient.listTargets/connect.
|
|
@@ -243,7 +258,7 @@ function failCdp(err, port = DEFAULT_CDP_PORT) {
|
|
|
243
258
|
/cannot reach|ECONNREFUSED|failed to attach/i.test(err.message)) {
|
|
244
259
|
console.error(cdpLaunchHint(port));
|
|
245
260
|
}
|
|
246
|
-
|
|
261
|
+
throw new CliExit(1);
|
|
247
262
|
}
|
|
248
263
|
/**
|
|
249
264
|
* Run a CDP command against the required endpoint (defaulting to localhost:9222
|
|
@@ -362,8 +377,7 @@ function printForwardedLogs(logs) {
|
|
|
362
377
|
// CDP mode: eval over Chrome remote debugging (full window access, no daemon).
|
|
363
378
|
async function evalViaCdp(cdp, flags, code, timeoutMs) {
|
|
364
379
|
try {
|
|
365
|
-
|
|
366
|
-
writeJson(value);
|
|
380
|
+
return await withCdp(cdp, str(flags['tab']), (c) => c.eval(code, { timeoutMs }));
|
|
367
381
|
}
|
|
368
382
|
catch (err) {
|
|
369
383
|
failCdp(err, cdp.port);
|
|
@@ -417,7 +431,7 @@ async function evalViaDaemon(flags, code, timeoutMs) {
|
|
|
417
431
|
if (!res.ok) {
|
|
418
432
|
const err = (await res.json().catch(() => ({})));
|
|
419
433
|
console.error(`eval failed: ${err.error ?? res.statusText}`);
|
|
420
|
-
|
|
434
|
+
throw new CliExit(1);
|
|
421
435
|
}
|
|
422
436
|
const result = (await res.json());
|
|
423
437
|
printForwardedLogs(result.logs);
|
|
@@ -425,9 +439,16 @@ async function evalViaDaemon(flags, code, timeoutMs) {
|
|
|
425
439
|
console.error(`script error: ${result.error}`);
|
|
426
440
|
if (result.stack)
|
|
427
441
|
console.error(result.stack);
|
|
428
|
-
|
|
442
|
+
throw new CliExit(2);
|
|
429
443
|
}
|
|
430
|
-
|
|
444
|
+
return result.value;
|
|
445
|
+
}
|
|
446
|
+
/** Run `code` in the tab and return its value; exits 2 if it threw, 1 otherwise. */
|
|
447
|
+
function evaluate(flags, code, timeoutMs) {
|
|
448
|
+
const cdp = cdpFromFlags(flags);
|
|
449
|
+
return cdp
|
|
450
|
+
? evalViaCdp(cdp, flags, code, timeoutMs)
|
|
451
|
+
: evalViaDaemon(flags, code, timeoutMs);
|
|
431
452
|
}
|
|
432
453
|
async function runEval(positional, flags) {
|
|
433
454
|
const code = await gatherCode(positional, flags);
|
|
@@ -435,11 +456,59 @@ async function runEval(positional, flags) {
|
|
|
435
456
|
console.error('eval: no script provided (pass code as an argument, --file, or stdin)');
|
|
436
457
|
process.exit(1);
|
|
437
458
|
}
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
459
|
+
writeJson(await evaluate(flags, code, timeoutFromFlags(flags)));
|
|
460
|
+
}
|
|
461
|
+
// Compressing and sizing a large photo in the tab takes far longer than a
|
|
462
|
+
// script. Kept under Node fetch's own 300 s wait for the daemon's answer.
|
|
463
|
+
const ASSET_TIMEOUT_MS = 240_000;
|
|
464
|
+
async function runUpload(positional, flags) {
|
|
465
|
+
if (positional.length === 0) {
|
|
466
|
+
console.error('upload: pass one or more files');
|
|
467
|
+
process.exit(1);
|
|
468
|
+
}
|
|
469
|
+
const options = uploadOptions(flags, positional.length);
|
|
470
|
+
for (const path of positional) {
|
|
471
|
+
assertUploadable(path, options.compress !== undefined);
|
|
472
|
+
}
|
|
473
|
+
// One file in memory at a time: each is read only when its turn comes.
|
|
474
|
+
const timeoutMs = timeoutFromFlags(flags) ?? ASSET_TIMEOUT_MS;
|
|
475
|
+
await eachReported(positional, (path) => evaluate(flags, uploadScript(uploadInput(path, options)), timeoutMs));
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Run `step` per item and print the results. When one fails, what already
|
|
479
|
+
* succeeded is printed before the failure exits, so the agent keeps the ids
|
|
480
|
+
* of the assets that did land.
|
|
481
|
+
*/
|
|
482
|
+
async function eachReported(items, step) {
|
|
483
|
+
const done = [];
|
|
484
|
+
try {
|
|
485
|
+
for (const item of items)
|
|
486
|
+
done.push(await step(item));
|
|
487
|
+
}
|
|
488
|
+
catch (err) {
|
|
489
|
+
if (done.length > 0) {
|
|
490
|
+
writeJson(done);
|
|
491
|
+
console.error(`${done.length} of ${items.length} done before the failure`);
|
|
492
|
+
}
|
|
493
|
+
throw err;
|
|
494
|
+
}
|
|
495
|
+
writeJson(done.length === 1 ? done[0] : done);
|
|
496
|
+
}
|
|
497
|
+
const isIdList = (ids) => ids.length > 0 && ids.every(Number.isInteger);
|
|
498
|
+
async function runDownload(positional, flags) {
|
|
499
|
+
const ids = positional.map(Number);
|
|
500
|
+
if (!isIdList(ids)) {
|
|
501
|
+
console.error('download: pass one or more numeric asset ids');
|
|
502
|
+
process.exit(1);
|
|
503
|
+
}
|
|
504
|
+
const out = flagValue('out', flags['out']) ?? '.';
|
|
505
|
+
const force = bool(flags['force'], false);
|
|
506
|
+
if (ids.length > 1 && !isDirectory(out)) {
|
|
507
|
+
console.error('download: --out must be an existing directory for several ids');
|
|
508
|
+
process.exit(1);
|
|
509
|
+
}
|
|
510
|
+
const timeoutMs = timeoutFromFlags(flags) ?? ASSET_TIMEOUT_MS;
|
|
511
|
+
await eachReported(ids, async (id) => saveDownload(id, await evaluate(flags, downloadScript(id), timeoutMs), out, { force }));
|
|
443
512
|
}
|
|
444
513
|
/** `shot` / `html` / `computed` are CDP-only; default to localhost:9222. */
|
|
445
514
|
function requireCdp(flags) {
|
|
@@ -490,6 +559,8 @@ Usage:
|
|
|
490
559
|
etch-connector serve [--ws-port ${DEFAULT_WS_PORT}] [--control-port ${DEFAULT_CONTROL_PORT}] [--ws-host ${DEFAULT_HOST}]
|
|
491
560
|
etch-connector tabs [--json] [--quiet] [--control-port ${DEFAULT_CONTROL_PORT}] [--cdp]
|
|
492
561
|
etch-connector eval [code] [-t|--tab name] [-f|--file path] [--timeout ms] [--cdp]
|
|
562
|
+
etch-connector upload <file…> [-t name] [--alt text] [--collection id] [--compress webp:80[:maxWidth]] [--cdp]
|
|
563
|
+
etch-connector download <id…> [-t name] [-o|--out dir|file] [--force] [--cdp]
|
|
493
564
|
etch-connector shot [-t name] [-s|--selector css] [--full] [--jpeg] [-o|--out file] [--freeze=false] (CDP)
|
|
494
565
|
etch-connector html <selector> [-t name] (CDP)
|
|
495
566
|
etch-connector computed <selector> [-t name] [--props a,b,c] (CDP)
|
|
@@ -582,6 +653,29 @@ it, narrate it, or explain these state changes — just keep it accurate):
|
|
|
582
653
|
etch.ai.setState('working');
|
|
583
654
|
try { /* …drive the builder… */ } finally { etch.ai.setState('thinking'); }
|
|
584
655
|
|
|
656
|
+
Assets (AGENT: where the runtime backs \`assets\` — see \`tabs\` — files live in
|
|
657
|
+
the product's own store, reached through etch.assets.*. NEVER call /wp/v2/media
|
|
658
|
+
there):
|
|
659
|
+
• To put a local image on a page: \`upload\` it, take the \`id\` from the
|
|
660
|
+
printed asset, and bind an etch/dynamic-image by \`mediaId\` (the id, as a
|
|
661
|
+
string). Never bind a URL.
|
|
662
|
+
• ALWAYS set alt text: --alt on upload (one file per command, so each gets
|
|
663
|
+
its own), or etch.assets.updateAsync(id, { alt }).
|
|
664
|
+
• Prefer --compress webp:80 for photos (add :2400 to cap the width); keep
|
|
665
|
+
PNG/SVG for logos and line art. Responsive sizes are generated either way.
|
|
666
|
+
• To inspect or edit an existing image: \`download\` it to a path you choose,
|
|
667
|
+
work on the file, then etch.assets.replaceAsync or upload a new one.
|
|
668
|
+
\`download\` never overwrites a file unless you pass --force.
|
|
669
|
+
• Organizing means collections — the Asset Manager shows them as folders:
|
|
670
|
+
etch.assets.createCollectionAsync(name), then updateAsync(id, { collectionIds }).
|
|
671
|
+
• The commands print JSON on stdout and exit 2 when the tab refuses (a taken
|
|
672
|
+
name, ASSET_IN_USE on delete, a runtime with no etch.assets).
|
|
673
|
+
• A TIMEOUT does not mean nothing was stored: the tab may still finish. Check
|
|
674
|
+
etch.assets.listAsync() before uploading again.
|
|
675
|
+
Examples:
|
|
676
|
+
etch-connector upload ./hero.jpg --alt "Team on the roof" --compress webp:80
|
|
677
|
+
etch-connector download 12 --out /tmp/hero.webp
|
|
678
|
+
|
|
585
679
|
Docs (AGENT: pull these BEFORE writing scripts against etch.* — the API surface
|
|
586
680
|
changes and training data may be stale):
|
|
587
681
|
Context7 MCP (preferred) resolve-library-id "Etch" → query-docs for the
|
|
@@ -636,39 +730,41 @@ render what you author):
|
|
|
636
730
|
REST API:
|
|
637
731
|
curl 'http://<site>/?rest_route=/wp/v2/media&per_page=100&_fields=id,source_url'
|
|
638
732
|
(rest_route form works on plain permalinks; /wp-json/wp/v2/media on pretty
|
|
639
|
-
ones.) Without it the ids are the product's own asset store
|
|
640
|
-
|
|
733
|
+
ones.) Without it the ids are the product's own asset store: use
|
|
734
|
+
etch.assets and \`upload\` where the runtime backs \`assets\` (see Assets).
|
|
641
735
|
|
|
642
736
|
See AGENTS.md for project conventions.`;
|
|
643
737
|
function printHelp() {
|
|
644
738
|
console.error(HELP_TEXT);
|
|
645
739
|
}
|
|
740
|
+
const COMMANDS = {
|
|
741
|
+
serve: (_, flags) => runServe(flags),
|
|
742
|
+
tabs: (_, flags) => runTabs(flags),
|
|
743
|
+
eval: runEval,
|
|
744
|
+
upload: runUpload,
|
|
745
|
+
download: runDownload,
|
|
746
|
+
shot: (_, flags) => runShot(flags),
|
|
747
|
+
html: runHtml,
|
|
748
|
+
computed: runComputed,
|
|
749
|
+
help: printHelp,
|
|
750
|
+
'--help': printHelp,
|
|
751
|
+
'-h': printHelp
|
|
752
|
+
};
|
|
646
753
|
async function main() {
|
|
647
754
|
const { command, positional, flags } = parseArgs(process.argv.slice(2));
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
case 'shot':
|
|
656
|
-
return runShot(flags);
|
|
657
|
-
case 'html':
|
|
658
|
-
return runHtml(positional, flags);
|
|
659
|
-
case 'computed':
|
|
660
|
-
return runComputed(positional, flags);
|
|
661
|
-
case 'help':
|
|
662
|
-
case '--help':
|
|
663
|
-
case '-h':
|
|
664
|
-
return printHelp();
|
|
665
|
-
default:
|
|
666
|
-
console.error(`unknown command: ${command}\n`);
|
|
667
|
-
printHelp();
|
|
668
|
-
process.exit(1);
|
|
755
|
+
const run = Object.hasOwn(COMMANDS, command)
|
|
756
|
+
? COMMANDS[command]
|
|
757
|
+
: undefined;
|
|
758
|
+
if (!run) {
|
|
759
|
+
console.error(`unknown command: ${command}\n`);
|
|
760
|
+
printHelp();
|
|
761
|
+
process.exit(1);
|
|
669
762
|
}
|
|
763
|
+
return run(positional, flags);
|
|
670
764
|
}
|
|
671
765
|
main().catch((err) => {
|
|
766
|
+
if (err instanceof CliExit)
|
|
767
|
+
process.exit(err.code);
|
|
672
768
|
console.error(String(err instanceof Error ? err.message : err));
|
|
673
769
|
process.exit(1);
|
|
674
770
|
});
|
package/dist/control-server.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { createServer } from 'node:http';
|
|
16
16
|
import { z } from 'zod';
|
|
17
|
+
import { MAX_COMPRESS_SOURCE_BYTES } from './assets.js';
|
|
17
18
|
import { BridgeError } from './bridge-server.js';
|
|
18
19
|
import { DEFAULT_CONTROL_PORT, DEFAULT_HOST } from './protocol.js';
|
|
19
20
|
const evalBodySchema = z.object({
|
|
@@ -22,6 +23,8 @@ const evalBodySchema = z.object({
|
|
|
22
23
|
timeoutMs: z.number().int().positive().optional(),
|
|
23
24
|
mode: z.enum(['safe', 'unsafe']).optional()
|
|
24
25
|
});
|
|
26
|
+
// An `upload` carries the file as base64 (4/3 of its size) inside the script.
|
|
27
|
+
const MAX_BODY_BYTES = Math.ceil((MAX_COMPRESS_SOURCE_BYTES * 4) / 3) + 5_000_000;
|
|
25
28
|
function send(res, status, body) {
|
|
26
29
|
const payload = JSON.stringify(body);
|
|
27
30
|
res.writeHead(status, {
|
|
@@ -36,8 +39,7 @@ function readBody(req) {
|
|
|
36
39
|
let size = 0;
|
|
37
40
|
req.on('data', (chunk) => {
|
|
38
41
|
size += chunk.length;
|
|
39
|
-
|
|
40
|
-
if (size > 5_000_000) {
|
|
42
|
+
if (size > MAX_BODY_BYTES) {
|
|
41
43
|
reject(new Error('request body too large'));
|
|
42
44
|
req.destroy();
|
|
43
45
|
return;
|
|
@@ -60,10 +62,51 @@ function statusForBridgeError(code) {
|
|
|
60
62
|
return 502;
|
|
61
63
|
}
|
|
62
64
|
}
|
|
65
|
+
async function handleEval(bridge, req, res, defaultMode) {
|
|
66
|
+
if (!req.headers['content-type']?.startsWith('application/json')) {
|
|
67
|
+
return send(res, 415, {
|
|
68
|
+
error: 'send the body as application/json'
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
let parsed;
|
|
72
|
+
try {
|
|
73
|
+
parsed = evalBodySchema.parse(JSON.parse(await readBody(req)));
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
return send(res, 400, {
|
|
77
|
+
error: `invalid request body: ${String(err)}`
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
const result = await bridge.evalInTab(parsed.code, {
|
|
82
|
+
tab: parsed.tab,
|
|
83
|
+
timeoutMs: parsed.timeoutMs,
|
|
84
|
+
mode: parsed.mode ?? defaultMode
|
|
85
|
+
});
|
|
86
|
+
return send(res, 200, result);
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
if (err instanceof BridgeError) {
|
|
90
|
+
return send(res, statusForBridgeError(err.code), {
|
|
91
|
+
error: err.message,
|
|
92
|
+
code: err.code
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
return send(res, 500, { error: String(err) });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
63
98
|
export function createControlServer(bridge, options = {}) {
|
|
64
99
|
const defaultMode = options.defaultMode ?? 'safe';
|
|
65
100
|
return createServer(async (req, res) => {
|
|
66
101
|
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
102
|
+
// Browsers always send Origin and local tools do not. A page can POST
|
|
103
|
+
// here without a preflight, or rebind its own name to this address, so
|
|
104
|
+
// anything a browser sends is refused before it can reach a tab.
|
|
105
|
+
if (req.headers.origin !== undefined) {
|
|
106
|
+
return send(res, 403, {
|
|
107
|
+
error: 'requests from web pages are refused'
|
|
108
|
+
});
|
|
109
|
+
}
|
|
67
110
|
if (req.method === 'GET' && url.pathname === '/health') {
|
|
68
111
|
return send(res, 200, { ok: true });
|
|
69
112
|
}
|
|
@@ -71,32 +114,7 @@ export function createControlServer(bridge, options = {}) {
|
|
|
71
114
|
return send(res, 200, { tabs: bridge.listTabs() });
|
|
72
115
|
}
|
|
73
116
|
if (req.method === 'POST' && url.pathname === '/eval') {
|
|
74
|
-
|
|
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
|
-
}
|
|
117
|
+
return handleEval(bridge, req, res, defaultMode);
|
|
100
118
|
}
|
|
101
119
|
send(res, 404, { error: `not found: ${req.method} ${url.pathname}` });
|
|
102
120
|
});
|
package/dist/guidance.js
CHANGED
|
@@ -81,6 +81,14 @@ function routesGuidance(environment) {
|
|
|
81
81
|
" templateId, or its nearest ancestor's (templateId: null in a patch",
|
|
82
82
|
' inherits again). Read getPathsAsync() after a change — only',
|
|
83
83
|
' `published` paths are built.',
|
|
84
|
+
' SEO lives on the route, set through updateAsync: title, description,',
|
|
85
|
+
' language, canonical, socialImageId, faviconId (root only),',
|
|
86
|
+
" searchVisibility ('inherit' | 'hidden' | 'visible'), redirect",
|
|
87
|
+
' ({ to, status: 301 | 302 }, never on the root) and notFoundId. null',
|
|
88
|
+
' clears a field so it inherits; title, description and canonical may',
|
|
89
|
+
" hold expressions ('{this.title} | {site.name}'). listSeoAsync() returns",
|
|
90
|
+
' every published page resolved — one call for the whole site, so read',
|
|
91
|
+
' it before and after a pass rather than getSeoAsync(path) per page.',
|
|
84
92
|
' Do not build links from .slug (this.slug, item.slug in a loop, or',
|
|
85
93
|
' PostSummary.slug): only a record a static route serves has one. A',
|
|
86
94
|
' record served by a dynamic route has none, and listPostsAsync() reports',
|
|
@@ -131,6 +139,18 @@ function mediaGuidance(environment) {
|
|
|
131
139
|
binding
|
|
132
140
|
];
|
|
133
141
|
}
|
|
142
|
+
if (hasCapability(environment, 'assets')) {
|
|
143
|
+
return [
|
|
144
|
+
"Media — this runtime's own asset store, through etch.assets.*, NOT the",
|
|
145
|
+
' WordPress library: never call /wp/v2/media here.',
|
|
146
|
+
" etch.assets.listAsync({ mimeType: 'image/' }) lists files with their ids.",
|
|
147
|
+
' A local file: `etch-connector upload <file> --alt "…"` prints the asset;',
|
|
148
|
+
' bind its id. To inspect or edit one: `etch-connector download <id> --out <path>`.',
|
|
149
|
+
' Folders are collections (createCollectionAsync, then updateAsync(id,',
|
|
150
|
+
' { collectionIds })). updateAsync(id, { alt }) sets alt text.',
|
|
151
|
+
binding
|
|
152
|
+
];
|
|
153
|
+
}
|
|
134
154
|
return [
|
|
135
155
|
"Media — this runtime's own asset store, NOT the WordPress library:",
|
|
136
156
|
' /wp/v2/media does not apply and there is nothing to look a URL up against.',
|
package/dist/protocol.js
CHANGED