@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 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. Three commands:
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
- process.exit(2);
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
- process.exit(1);
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
- const value = await withCdp(cdp, str(flags['tab']), (c) => c.eval(code, { timeoutMs }));
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
- process.exit(1);
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
- process.exit(2);
442
+ throw new CliExit(2);
429
443
  }
430
- writeJson(result.value);
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
- const timeoutMs = timeoutFromFlags(flags);
439
- const cdp = cdpFromFlags(flags);
440
- return cdp
441
- ? evalViaCdp(cdp, flags, code, timeoutMs)
442
- : evalViaDaemon(flags, code, timeoutMs);
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 and there is
640
- nothing to look a URL up against.
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
- switch (command) {
649
- case 'serve':
650
- return runServe(flags);
651
- case 'tabs':
652
- return runTabs(flags);
653
- case 'eval':
654
- return runEval(positional, flags);
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
  });
@@ -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
- // Guard against an unbounded body; scripts are small.
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
- 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
- }
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
@@ -18,7 +18,8 @@ const CAPABILITY_NAMES = [
18
18
  'templates',
19
19
  'wpMedia',
20
20
  'routes',
21
- 'content'
21
+ 'content',
22
+ 'assets'
22
23
  ];
23
24
  /** Whether a runtime backs a capability. Absent or unknown counts as no. */
24
25
  export function hasCapability(environment, capability) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@digital-gravy/etch-connector",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },