Skip to content

Instantly share code, notes, and snippets.

@dckc
Last active August 29, 2026 21:30
Show Gist options
  • Select an option

  • Save dckc/783ee2062a441e5c1b4a2eb74fe26545 to your computer and use it in GitHub Desktop.

Select an option

Save dckc/783ee2062a441e5c1b4a2eb74fe26545 to your computer and use it in GitHub Desktop.
Counter caplet with buttons on minion.town — a ~3KB hand-rolled CapTP client driving a live counter remotable over a weblet's own websocket

Counter caplet with buttons on minion.town

A published minion.town weblet whose +/- buttons drive a live, durable counter remotable over the weblet's own CapTP websocket — using a ~3KB hand-rolled CapTP client, no SES, no bundler.

Live URL: https://zwopgrfwiu5tfdkxqzhbvrrr4z6zvsqw5tslxj5j7mr4la265bja.ocap.site/

The chain

  1. counter — a live incr/decr/read remotable stored under the pet name counter in the daemon guest (see counter-caplet.md).
  2. The weblet is published with powers: "counter", which becomes its back capability.
  3. GET <hash>.ocap.site/ serves the static front (index.html + main.js + app.js + captp.js).
  4. WS <hash>.ocap.site/.well-known/endo-captp presents the counter as the CapTP session bootstrap.
  5. app.js (the hand-rolled client) opens that WebSocket and drives read/incr/decr; the buttons call them and refresh the display.

Files (layered)

The front is split into four layers, each depending only on the one below it:

  • index.html — the page: a display plus +/- buttons. Loads main.js as an external module (<script type="module" src="main.js">).
  • main.js — the boot layer: injects makeWebSocket (url => new WebSocket(url)) as an endowment, wires the DOM, and calls start(...). Kept external (not inline) to satisfy the weblet CSP script-src 'self'.
  • app.js — the app layer: imports makeCapTP from captp.js, opens the WebSocket, and wires the buttons. No DOM access at import time.
  • captp.js — the protocol layer: makeCapTP(ourId, rawSend, bootstrapObj, opts) returns { getBootstrap, dispatch, abort, call }, API-shaped like @endo/captp. Transport-agnostic (takes a rawSend function).
  • counter-caplet.js — the counter caplet source (a Far('Counter', …) with incr/decr/read), deployed under the pet name counter. Far is available as a worker endowment on the evaluate route, so no import is needed.
  • weblet-publish-payload.py — emits the base64 content payload for the weblet_publish MCP tool, with a typo guard.
  • counter-caplet.md — how to deploy the durable counter remotable.
  • counter-caplet-buttons.md — the full write-up: dead ends, the win, and the debugging journey.

The minimal CapTP client (the interesting part)

captp.js speaks just enough of the protocol to call the counter:

  • open a WebSocket to wss://<host>/.well-known/endo-captp
  • CTP_BOOTSTRAP → get the counter remotable slot
  • CTP_CALL {questionID, target, method}CTP_RETURN {answerID, result}
  • minimal marshal: {body, slots} capdata, primitives + slot refs
  • slot-direction reversal: the server exports o+1; the client imports it as o-1 (reverseSlot).

The bugs we hit (and fixed)

  • Text-frame decode. The gateway sends CTP_RETURN as a text frame, so ev.data is a string; new TextDecoder().decode(ev.data) threw on a string. Fixed by typeof ev.data === 'string' ? ev.data : new TextDecoder().decode(ev.data).
  • CTP_BOOSTRAP typo. A misspelled protocol message type (should be CTP_BOOTSTRAP) made the server ignore the bootstrap, so the client hung. It crept in while hand-assembling the base64 publish payload.
  • CSP blocks inline scripts. The weblet serves script-src 'self', so an inline <script type="module"> was rejected. Fixed by moving the boot wiring into an external main.js.
  • Sending while CONNECTING. After the refactor, getBootstrap() ran before the socket opened, throwing InvalidStateError. Fixed by awaiting an opened promise (resolved on ws.onopen) before sending CTP_BOOTSTRAP.

Lessons

  • Publish from generated byte strings, never hand-typed base64. Use weblet-publish-payload.py and copy its output verbatim.
  • Verify the published artifact, not just the source. curl <url>/app.js | grep <token> after publish — the source can be right while the payload is wrong.
  • Console logging is the debugging instrument. When a WebSocket client hangs browser-but-works-node, the console trace (send vs recv) pinpoints whether the request left or the reply stalled.
  • A tiny domain-specific client beats a general bundler for a small surface. For a counter, hand-rolling ~3KB of CapTP was simpler than shipping SES + a 650KB bundle.
  • Layering pays off. Splitting the front into boot/app/protocol layers made each bug (CSP, CONNECTING) local and easy to fix, and kept the transport injectable for testing.
// The counter caplet front: wire the + / - buttons to the counter remotable
// over the weblet's own CapTP websocket.
//
// This module is a plain ESM module (no DOM access at import time). The HTML
// page injects `makeWebSocket` (a `url => WebSocket` factory) as an endowment
// and calls `start({ makeWebSocket, display, incrBtn, decrBtn })`.
import { makeCapTP } from './captp.js';
export async function start({ makeWebSocket, display, incrBtn, decrBtn }) {
const ws = makeWebSocket(`wss://${location.host}/.well-known/endo-captp`);
ws.binaryType = 'arraybuffer';
const { getBootstrap, dispatch, abort, call } = makeCapTP(
'counter-front',
(obj) => ws.send(new TextEncoder().encode(JSON.stringify(obj))),
);
// Wait for the socket to open before sending CTP_BOOTSTRAP (sending while
// CONNECTING throws InvalidStateError).
const opened = new Promise((resolve, reject) => {
ws.onopen = resolve;
ws.onerror = (e) => reject(new Error('ws error'));
});
ws.onmessage = (ev) => {
// The gateway sends text frames, so ev.data is a string; be robust to binary too.
const text = typeof ev.data === 'string' ? ev.data : new TextDecoder().decode(ev.data);
let msg;
try {
msg = JSON.parse(text);
} catch (e) {
console.warn('[captp] bad frame', e, text);
return;
}
dispatch(msg);
};
ws.onclose = () => abort(new Error('closed'));
await opened;
// After open, a later socket error should abort in-flight questions.
ws.onerror = (e) => { console.error('[captp] ws error', e); abort(new Error('ws error')); };
const counter = await getBootstrap();
const counterSlot = counter.slot;
const read = async () => await call(counterSlot, 'read', []);
const incr = async () => await call(counterSlot, 'incr', []);
const decr = async () => await call(counterSlot, 'decr', []);
const refresh = async () => { display.textContent = String(await read()); };
incrBtn.addEventListener('click', async () => { await incr(); await refresh(); });
decrBtn.addEventListener('click', async () => { await decr(); await refresh(); });
await refresh();
}
// Minimal hand-rolled CapTP client, API-shaped like @endo/captp's makeCapTP.
//
// makeCapTP(ourId, rawSend, bootstrapObj, opts) -> { getBootstrap, dispatch, abort, call }
//
// Speaks just enough of the protocol to call methods that return primitives:
// - CTP_BOOTSTRAP -> get the peer's bootstrap remotable slot
// - CTP_CALL -> invoke a method on a slot
// - CTP_RETURN -> the answer to a question
// plus a minimal {body, slots} marshal (primitives + slot references).
//
// `rawSend` is a function that sends a JSONable packet (e.g. over a WebSocket).
// `dispatch` is called with each inbound packet. `getBootstrap()` returns a
// promise for the bootstrap (here: { slot }). `abort(reason)` rejects all
// in-flight questions.
export function makeCapTP(ourId, rawSend, bootstrapObj = undefined, opts = {}) {
const { epoch = 0 } = opts;
let nextQuestion = 0;
const pending = new Map(); // questionID -> { resolve, reject }
const send = (obj) => rawSend(obj);
// Reverse a slot direction: the peer exports o+1; we import it as o-1.
const reverseSlot = (s) => s[0] + (s[1] === '+' ? '-' : '+') + s.slice(2);
const unserialize = (capdata) => {
const { body, slots = [] } = capdata;
const parsed = JSON.parse(body);
if (parsed && parsed['@qclass'] === 'slot') {
return { slot: reverseSlot(slots[parsed.index]) };
}
return parsed;
};
const serialize = (value) => ({ body: JSON.stringify(value), slots: [] });
const call = (target, prop, args) => {
const questionID = `q-${++nextQuestion}`;
const p = new Promise((resolve, reject) =>
pending.set(questionID, { resolve, reject }),
);
send({ type: 'CTP_CALL', epoch, questionID, target, method: serialize([prop, args]) });
return p;
};
const dispatch = (msg) => {
if (msg.type === 'CTP_RETURN') {
const entry = pending.get(msg.answerID);
if (!entry) return;
pending.delete(msg.answerID);
if ('exception' in msg) {
entry.reject(new Error(JSON.stringify(msg.exception)));
} else {
entry.resolve(unserialize(msg.result));
}
}
};
const getBootstrap = () => {
const questionID = `q-${++nextQuestion}`;
const p = new Promise((resolve, reject) =>
pending.set(questionID, { resolve, reject }),
);
send({ type: 'CTP_BOOTSTRAP', epoch, questionID });
return p;
};
const abort = (reason) => {
for (const { reject } of pending.values()) reject(reason);
pending.clear();
};
return { getBootstrap, dispatch, abort, call };
}

Counter caplet with buttons (minimal hand-rolled CapTP) — 2026-08-28

Status: WORKING. A published minion.town weblet whose +/- buttons drive a live, durable counter remotable over the weblet's own CapTP websocket.

Live URL: https://zwopgrfwiu5tfdkxqzhbvrrr4z6zvsqw5tslxj5j7mr4la265bja.ocap.site/ (see weblet_publish payload helper: weblet-publish-payload.md)

The chain

  • counter — a live incr/decr/read remotable stored under the pet name (see counter-caplet.md).
  • The weblet is published with powers: "counter", which becomes its back capability.
  • GET <hash>.ocap.site/ serves the static front (index.html + main.js + app.js + captp.js).
  • WS <hash>.ocap.site/.well-known/endo-captp presents the counter as the CapTP session bootstrap.
  • app.js (a hand-rolled ~3KB CapTP client) opens that WebSocket and drives read/incr/decr; the buttons call them and refresh the display.

The layered front

The front is split into four layers, each depending only on the one below it:

  • index.html — the page; loads main.js as an external module (<script type="module" src="main.js">).
  • main.js — the boot layer: injects makeWebSocket (url => new WebSocket(url)) as an endowment, wires the DOM, calls start(...).
  • app.js — the app layer: imports makeCapTP, opens the WebSocket, wires the buttons. No DOM access at import time.
  • captp.js — the protocol layer: makeCapTP(ourId, rawSend, bootstrapObj, opts){ getBootstrap, dispatch, abort, call }, API-shaped like @endo/captp, transport-agnostic.

Dead end 1: returning a Far object from guest_eval

A Far('Counter', …) returned from guest_eval renders as {} in the tool output — but it is NOT flattened/lost. The {} is just the tool's JSON render (JSON.stringify skips function-valued own properties that make up a remotable). The object is intact; it just isn't JSON-serializable. Deploying a live object therefore requires the daemon's evaluate with a resultName, which stores the remotable in the formula graph (see counter-caplet.md).

Dead end 2: worker globals do not persist

Each guest_eval runs in a fresh context. globalThis state does not survive between calls, so a counter backed by a worker global loses its value across tool calls. State must live in the guest directory (durable) or in a stored remotable's closure (the resultName route).

Dead end 3: endoScript bundle + SES is too heavy for the publish tool

The "real" front bundled the app with @endo/bundle-source -f endoScript and shipped SES (ses.umd.js ~233KB minified) to run it in a browser Compartment. The publish payload is ~650KB — too large to pass through the weblet_publish tool call (and the read tool truncates it). The import-map alternative (serve the raw @endo/* ESM tree) was designed but set aside as fiddly (see import-map-front.md). Both are viable in principle; neither fits the MCP publish path as-is.

The win: minimal hand-rolled CapTP client (~3KB, no SES, no bundler)

The counter's protocol is tiny: bootstrap once, then CTP_CALL methods that return numbers. So captp.js implements just that slice:

  • open a WebSocket to wss://<host>/.well-known/endo-captp
  • CTP_BOOTSTRAP → get the counter remotable slot
  • CTP_CALL {questionID, target, method}CTP_RETURN {answerID, result}
  • minimal marshal: {body, slots} capdata, primitives + slot refs

Verified end-to-end on the live edge (a Node client driving the same wire):

bootstrap: o-1
read: 12
incr: 13
read: 13

The debugging journey (console logging won)

  1. bootP never resolves in the browser, but worked in Node.
  2. Added console.log in send, onmessage, ws.onopen.
  3. Console showed: [captp] ws open, [captp] send CTP_BOOSTRAP — no recv ever.
  4. CTP_BOOSTRAP is a typo. It should be CTP_BOOTSTRAP (one word, one O in the second syllable). The server has no handler for CTP_BOOSTRAP, so it never answers and bootP hangs.
  5. The typo was in the published bytes, while the local file was correct — it crept in while hand-assembling the base64 publish payload (same failure class as the earlier mrgs typo in onmessage).

The bugs that looked like each other

  • Text-frame decode. The gateway sends CTP_RETURN as a text frame, so ev.data is a string; new TextDecoder().decode(ev.data) threw on a string. Fixed by typeof ev.data === 'string' ? ev.data : new TextDecoder().decode(ev.data).
  • CTP_BOOSTRAP typo. Protocol message type misspelled, so the server ignored the bootstrap. Fixed by publishing the correct bytes.
  • CSP blocks inline scripts. The weblet serves script-src 'self', so an inline <script type="module"> was rejected. Fixed by moving the boot wiring into an external main.js.
  • Sending while CONNECTING. After the refactor, getBootstrap() ran before the socket opened, throwing InvalidStateError. Fixed by awaiting an opened promise (resolved on ws.onopen) before sending CTP_BOOTSTRAP.

Lessons

  • Publish from generated byte strings, never hand-typed base64. Both typos were transcription errors I introduced. Use the payload script and copy its output verbatim.
  • Verify the published artifact, not just the source. curl <url>/app.js | grep <token> after publish — the source can be right while the payload is wrong.
  • Console logging is the debugging instrument. When a WebSocket client hangs browser-but-works-node, the console trace (send vs recv) pinpoints whether the request left or the reply stalled, and whether the wire bytes are what you think they are.
  • A tiny domain-specific client beats a general bundler for a small surface. For a counter, hand-rolling ~3KB of CapTP was simpler than shipping SES + a 650KB bundle.
  • Layering pays off. Splitting the front into boot/app/protocol layers made each bug (CSP, CONNECTING) local and easy to fix, and kept the transport injectable for testing.
// @ts-nocheck
/* global Far */
export const make = (_powers, _context) => {
let count = 0;
return Far('Counter', {
incr() {
count += 1;
return count;
},
decr() {
count -= 1;
return count;
},
read() {
return count;
},
help() {
return 'a stateful counter caplet with incr/decr/read';
},
});
};

Building a durable counter caplet on minion.town (2026-08-28)

How to deploy a live, callable counter (incr / decr / read) under a pet name on minion.town's Endo daemon, using only the MCP guest tools — and the two dead ends that forced the working approach.

The goal

A counter object with incr and decr methods, stored under a pet name so it survives and can be driven across sessions.

The surface we have

The MCP server exposes guest_eval (evaluate source in a confined worker) and guest_write_text / guest_read_text (durable guest directory). The worker globals are: E, Far, M, makeExo, assert, console, URL, TextEncoder, TextDecoder (plus non-enumerable Compartment, lockdown, harden).

Dead end 1: returning a Far object from guest_eval

// guest_eval(worker, source, [], [])
let n = 0;
const c = Far('Counter', { incr: () => (n += 1), decr: () => (n -= 1), read: () => n });
return c;
// => {}   (the tool's JSON rendering of a remotable)

The object is not flattened or lost at the boundary — it is a genuine remotable (own properties incr, __getMethodNames__). The {} is purely an artifact of the tool's output path: the handler renders the result with JSON.stringify(value, null, 2), and JSON.stringify only serializes enumerable own data properties. A remotable's own properties are functions, which JSON.stringify skips, so it renders as {}. The live object is intact; it just cannot be represented as JSON. That is why storing it via @agent.evaluate(..., 'counter') works — the daemon keeps the live reference, and only the display of the return value is {}.

Dead end 2: worker globals do not persist

// call 1
globalThis.__counter = { n: 0, incr() { return ++this.n; } };
// call 2
globalThis.__counter; // => undefined

Each guest_eval runs in a fresh context; nothing persists between calls. So state cannot live in the worker.

The key: @agent is the full guest exo

Bind the special name @agent as an endowment. It resolves to the full EndoGuest exo, whose method surface includes writeText, readText, remove, list, has, lookup, and — crucially — evaluate with a resultName argument.

// guest_eval(worker, source, ["agent"], ["@agent"])
const names = await E(agent).__getMethodNames__();
// => [..., "evaluate", "has", "lookup", "writeText", "readText", ...]

@self and @host also bind, but only to a mailbox handle (open/receive/openEdit/receiveEdit) — not the directory. @agent is the one that reaches the durable directory and the daemon's real evaluate.

The working recipe

1. Deploy the counter as a live remotable under the pet name counter

// guest_eval(worker, source, ["agent"], ["@agent"])
const r = await E(agent).evaluate(
  'worker',                       // worker pet name (created on first use)
  'let n = 0; Far("Counter", { incr: () => (n += 1), decr: () => (n -= 1), read: () => n })',
  [],                             // endowment names
  [],                             // endowment values
  'counter',                      // resultName — stores the live object
);
// => {}   (the marshaled presence; the object itself is stored, not returned)

The daemon's evaluate accepts a resultName and persists the resulting remotable in the formula graph under that pet name. The {} return is just the presence crossing the boundary — the object is durably stored.

2. Verify it is stored and callable

// guest_eval(worker, source, ["agent"], ["@agent"])
const has = await E(agent).has('counter');          // => true
const c = await E(agent).lookup('counter');
const names = await E(c).__getMethodNames__();      // => ["decr","incr","read"]

3. Drive it

// guest_eval(worker, source, ["agent"], ["@agent"])
const c = await E(agent).lookup('counter');
const r0 = await E(c).read();   // 0
const r1 = await E(c).incr();   // 1
const r2 = await E(c).incr();   // 2
const r3 = await E(c).decr();   // 1
const r4 = await E(c).read();   // 1
// => [0, 1, 2, 1, 1]   (the town-notes' target sequence)

Why this works

  • @agent is the caller's own guest exo, so evaluate runs in the guest's authority and can name a result in the guest's directory.
  • The resultName makes the daemon persist the remotable in its formula graph, so it survives daemon restarts and is callable across sessions.
  • The counter's n lives in the worker's closure, so it persists across calls while the daemon lives; it resets to 0 on a daemon restart (the town-notes' durability caveat). Making the value itself durable would require the counter to read/write its state through @agent's writeText/readText instead.

The gap in the MCP tool

The guest_eval tool has no resultName argument — its schema is only worker, source, names, values, and it returns the value without naming it. The daemon's EndoGuest.evaluate supports resultName, but the tool does not expose it. So through the tool alone you cannot "name the result abc"; you must go through @agent (or store the returned text yourself, which loses the live object). A resultName arg on guest_eval would let the tool deploy durable caplets directly.

Import-map front for the counter caplet (design summary, 2026-08-28)

Status: set aside — explored, viable, not pursued. This records what we learned so the thread can be resumed without re-deriving it.

Context

We built a durable counter caplet on minion.town (see counter-caplet.md): a live remotable (incr/decr/read) stored under the pet name counter, reachable as the back capability of a published weblet over its own /.well-known/endo-captp WebSocket. The remaining piece is the front: an HTML page with +/- buttons that open that websocket and drive the counter.

The served weblet CSP is script-src 'self' (no inline scripts), so the front must be external .js files. The question was how to get @endo/captp (ESM with bare specifiers) into the browser.

The design: import maps instead of bundling

Serve the @endo/* ESM source tree as files in the weblet and resolve the bare specifiers with a browser import map (<script type="importmap">), so the app logic stays unbundled and readable.

Why it's viable (verified)

  • The @endo/captp transitive closure is browser-clean: no node: builtins, all packages are type: module (ESM). Traced 60 files across captp, errors, eventual-send, harden, marshal, nat, pass-style, promise-kit, plus common/object-map and env-options.
  • SES still loads first as its own file (ses.umd.jslockdown()), because @endo/captp needs harden/assert as globals. Import maps don't change that.

The two real caveats

  1. @endo/eventual-send must use the shim. Its . export maps to ./src/no-shim.js, which assumes a native HandledPromise. In a browser the import map must point @endo/eventual-send at ./shim.js instead.
  2. ses is a type-only import in errors/index.js and marshal/src/marshal-justin.js (JSDoc @import), so it does not need to be a runtime import-map entry — but the map must not choke on it.

File set

The traced closure is ~60 files. The import map maps each @endo/* bare specifier (and the ./subpath exports) to the served file paths. The weblet would carry: index.html, ses.umd.js, app.js (unbundled, uses bare specifiers), and the @endo/* tree.

Why we set it aside

  • It's a lot of small files to publish and a fiddly map to maintain.
  • The alternative (bundle only the stable @endo/captp library once into a single captp.js, keep app.js unbundled) is strictly fewer files and one build step, with the same "app stays readable" benefit.
  • Both are better than bundling the app itself.

Refs

  • @endo/captp entry: packages/captp/src/index.js
  • @endo/eventual-send shim: packages/eventual-send/shim.js
  • SES UMD bundle: packages/ses/dist/ses.umd.js (built via packages/ses-test/scripts/bundle.js)
  • endoScript bundler (the other option): @endo/bundle-source -f endoScript
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Counter Caplet</title>
<style>
body { font-family: system-ui; text-align: center; padding: 2rem; }
#display { font-size: 3rem; font-weight: bold; }
button { font-size: 1.5rem; padding: 0.5rem 1rem; }
#decr { margin-left: 1rem; }
</style>
</head>
<body>
<h1>Counter</h1>
<p id="display">?</p>
<button id="incr">+</button>
<button id="decr">-</button>
<script type="module" src="main.js"></script>
</body>
</html>
// Boot layer: inject the WebSocket factory and wire the DOM, then start the app.
// Kept as an external module (not inline) so it satisfies the weblet CSP
// `script-src 'self'` (inline scripts are blocked).
import { start } from './app.js';
const makeWebSocket = (url) => new WebSocket(url);
start({
makeWebSocket,
display: document.getElementById('display'),
incrBtn: document.getElementById('incr'),
decrBtn: document.getElementById('decr'),
});

Publishing a weblet via weblet_publish

The weblet_publish tool takes content as a JSON array of { path, bytes, contentType } with bytes base64-encoded. The payload can be large and any hand-transcription error in the tool call silently corrupts the published file.

The helper

Use weblet-publish-payload.py to emit the payload as a file, then read it back and paste it into weblet_publish verbatim — never hand-type the base64.

python3 weblet-publish-payload.py --token CTP_BOOTSTRAP --bad-token CTP_BOOSTRAP index.html app.js > payload.json
  • Writes a JSON array of {path, bytes, contentType} to stdout.
  • Before emitting, verifies every .js file round-trips and carries --token and not --bad-token, so a mangled payload is caught before publish.

Lessons that shaped the helper

  1. Never hand-type base64. Two failures (mrgs typo in onmessage; the CTP_BOOSTRAP typo that made the browser hang) both came from hand- assembling the base64 string in the tool-call JSON.
  2. Verify the published artifact, not just the source. After publish, curl https://<hash>.ocap.site/app.js | grep <token> — the local file can be correct while the payload you pasted is wrong.
  3. Publish is a separate, fallible act. The source and the wire bytes can diverge; make the payload mechanical (scripted) and the check explicit.
#!/usr/bin/env python3
"""Emit the base64 `content` payload for the weblet_publish MCP tool.
Usage:
python3 weblet-publish-payload.py index.html app.js > payload.json
Writes a JSON array of { path, bytes (base64), contentType } to stdout, ready
to paste into `weblet_publish`'s `content` argument.
Verification: re-decode the emitted app.js/script bytes and assert a
known-good token (default CTP_BOOTSTRAP) is present and its bad twin is absent,
so a hand-transcription error in your tool call is caught before publish.
Pass --token X and --bad-token Y to override.
"""
import argparse
import base64
import json
import sys
def b64(path: str) -> str:
return base64.b64encode(open(path, "rb").read()).decode()
def content_type(name: str) -> str:
if name.endswith(".html"):
return "text/html"
if name.endswith(".js"):
return "application/javascript"
if name.endswith(".css"):
return "text/css"
if name.endswith(".json"):
return "application/json"
return "application/octet-stream"
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("files", nargs="+", help="file paths, e.g. index.html app.js")
ap.add_argument("--token", help="token that MUST appear in every .js file")
ap.add_argument("--bad-token", help="token that must NOT appear (typo guard)")
args = ap.parse_args()
payload = [
{"path": f, "bytes": b64(f), "contentType": content_type(f)}
for f in args.files
]
# Verify: each JavaScript file round-trips and carries the expected token
# and not its misspelled twin. Catches hand-assembly typos before publish.
for item in payload:
if not item["path"].endswith(".js"):
continue
decoded = base64.b64decode(item["bytes"]).decode("utf-8", "replace")
if args.token:
assert args.token in decoded, (
f"{item['path']}: missing {args.token!r} after publish; bytes are wrong"
)
if args.bad_token:
assert args.bad_token not in decoded, (
f"{item['path']}: found unexpected {args.bad_token!r}; typo check failed"
)
json.dump(payload, sys.stdout)
print()
return 0
if __name__ == "__main__":
raise SystemExit(main())
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment